Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
152
webapp/channels/src/components/payment_form/address_form.tsx
Обычный файл
152
webapp/channels/src/components/payment_form/address_form.tsx
Обычный файл
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {FormattedMessage, MessageDescriptor, useIntl} from 'react-intl';
|
||||
|
||||
import {Address} from '@mattermost/types/cloud';
|
||||
import DropdownInput from 'components/dropdown_input';
|
||||
import {COUNTRIES} from 'utils/countries';
|
||||
import Input from 'components/widgets/inputs/input/input';
|
||||
|
||||
import StateSelector from './state_selector';
|
||||
|
||||
import './payment_form.scss';
|
||||
|
||||
type AddressFormProps = {
|
||||
onAddressChange: (address: Address) => void;
|
||||
onBlur: () => void;
|
||||
title: MessageDescriptor;
|
||||
formId: string;
|
||||
address: Address;
|
||||
}
|
||||
|
||||
const AddressForm = (props: AddressFormProps) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const handleCountryChange = (option: any) => {
|
||||
props.onAddressChange({...props.address, country: option.value});
|
||||
};
|
||||
|
||||
const handleStateChange = (option: any) => {
|
||||
props.onAddressChange({...props.address, state: option});
|
||||
};
|
||||
|
||||
const handleInputChange = (key: keyof Address) => (
|
||||
event:
|
||||
| React.ChangeEvent<HTMLInputElement>
|
||||
| React.ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
const target = event.target;
|
||||
const value = target.value;
|
||||
|
||||
const newStateValue = {
|
||||
[key]: value,
|
||||
} as unknown as Pick<Address, keyof Address>;
|
||||
|
||||
const {onAddressChange} = props;
|
||||
onAddressChange({
|
||||
...props.address,
|
||||
...newStateValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.formId}
|
||||
className='PaymentForm'
|
||||
>
|
||||
<div className='section-title'>
|
||||
<FormattedMessage
|
||||
{...props.title}
|
||||
/>
|
||||
</div>
|
||||
<DropdownInput
|
||||
onChange={handleCountryChange}
|
||||
value={
|
||||
props.address.country ? {value: props.address.country, label: props.address.country} : undefined
|
||||
}
|
||||
options={COUNTRIES.map((country) => ({
|
||||
value: country.name,
|
||||
label: country.name,
|
||||
}))}
|
||||
legend={formatMessage({
|
||||
id: 'payment_form.country',
|
||||
defaultMessage: 'Country',
|
||||
})}
|
||||
placeholder={formatMessage({
|
||||
id: 'payment_form.country',
|
||||
defaultMessage: 'Country',
|
||||
})}
|
||||
name={'billing_dropdown'}
|
||||
/>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='address'
|
||||
type='text'
|
||||
value={props.address.line1}
|
||||
onChange={handleInputChange('line1')}
|
||||
onBlur={props.onBlur}
|
||||
placeholder={formatMessage({
|
||||
id: 'payment_form.address',
|
||||
defaultMessage: 'Address',
|
||||
})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='address2'
|
||||
type='text'
|
||||
value={props.address.line2}
|
||||
onChange={handleInputChange('line2')}
|
||||
onBlur={props.onBlur}
|
||||
placeholder={formatMessage({
|
||||
id: 'payment_form.address_2',
|
||||
defaultMessage: 'Address 2',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='city'
|
||||
type='text'
|
||||
value={props.address.city}
|
||||
onChange={handleInputChange('city')}
|
||||
onBlur={props.onBlur}
|
||||
placeholder={formatMessage({
|
||||
id: 'payment_form.city',
|
||||
defaultMessage: 'City',
|
||||
})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<div className='form-row-third-1 selector'>
|
||||
<StateSelector
|
||||
country={props.address.country}
|
||||
state={props.address.state}
|
||||
onChange={handleStateChange}
|
||||
onBlur={props.onBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row-third-2'>
|
||||
<Input
|
||||
name='postalCode'
|
||||
type='text'
|
||||
value={props.address.postal_code}
|
||||
onChange={handleInputChange('postal_code')}
|
||||
onBlur={props.onBlur}
|
||||
placeholder={formatMessage({
|
||||
id: 'payment_form.zipcode',
|
||||
defaultMessage: 'Zip/Postal Code',
|
||||
})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressForm;
|
||||
8
webapp/channels/src/components/payment_form/card_image.css
Обычный файл
8
webapp/channels/src/components/payment_form/card_image.css
Обычный файл
@@ -0,0 +1,8 @@
|
||||
.CardImage {
|
||||
width: auto;
|
||||
max-width: 30px;
|
||||
height: auto;
|
||||
max-height: 30px;
|
||||
margin-top: -1px;
|
||||
margin-right: 9px;
|
||||
}
|
||||
54
webapp/channels/src/components/payment_form/card_image.tsx
Обычный файл
54
webapp/channels/src/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 (
|
||||
<img
|
||||
className='CardImage'
|
||||
src={cardImageSrc}
|
||||
alt={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 '';
|
||||
}
|
||||
36
webapp/channels/src/components/payment_form/card_input.css
Обычный файл
36
webapp/channels/src/components/payment_form/card_input.css
Обычный файл
@@ -0,0 +1,36 @@
|
||||
.StripeElement {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 2px !important;
|
||||
padding-left: 12px;
|
||||
background-color: var(--center-channel-bg);
|
||||
background-image: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
color: var(--center-channel-color);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.StripeElement--invalid {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.StripeElement::placeholder {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.StripeElement:focus::placeholder {
|
||||
color: 'transparent';
|
||||
}
|
||||
|
||||
.StripeElement:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px var(--button-bg);
|
||||
}
|
||||
204
webapp/channels/src/components/payment_form/card_input.tsx
Обычный файл
204
webapp/channels/src/components/payment_form/card_input.tsx
Обычный файл
@@ -0,0 +1,204 @@
|
||||
// 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 {Theme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {toRgbValues} from 'utils/utils';
|
||||
|
||||
import 'components/widgets/inputs/input/input.scss';
|
||||
|
||||
import './card_input.css';
|
||||
|
||||
type OwnProps = {
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
forwardedRef?: any;
|
||||
theme: Theme;
|
||||
onBlur?: () => void;
|
||||
onFocus?: () => void;
|
||||
className?: string;
|
||||
|
||||
// Stripe doesn't give type exports
|
||||
[propName: string]: any;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
elements: StripeElements | null | undefined;
|
||||
onCardInputChange?: (event: StripeCardElementChangeEvent) => void;
|
||||
} & 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<Props, State> {
|
||||
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});
|
||||
if (this.props.onCardInputChange) {
|
||||
this.props.onCardInputChange(event);
|
||||
}
|
||||
}
|
||||
|
||||
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 = (
|
||||
<FormattedMessage
|
||||
id='payment.field_required'
|
||||
defaultMessage='This field is required'
|
||||
/>);
|
||||
} else if (error === VALID_CARD_TEXT) {
|
||||
errorMessage = (
|
||||
<FormattedMessage
|
||||
id='payment.invalid_card_number'
|
||||
defaultMessage='Please enter a valid credit card'
|
||||
/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='Input___error'>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
{errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public getCard(): StripeCardElement | null | undefined {
|
||||
return this.props.elements?.getElement(CardElement);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {className, error: propError, theme, ...otherProps} = this.props;
|
||||
const CARD_ELEMENT_OPTIONS = {
|
||||
hidePostalCode: true,
|
||||
style: {
|
||||
base: {
|
||||
fontFamily: "'Open Sans', sans-serif",
|
||||
fontSize: '14px',
|
||||
fontSmoothing: 'antialiased',
|
||||
color: theme.centerChannelColor,
|
||||
'::placeholder': {
|
||||
color: `rgba(${toRgbValues(theme.centerChannelColor)}, 0.64)`,
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: theme.errorTextColor,
|
||||
iconColor: theme.errorTextColor,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className='Input_container'>
|
||||
<fieldset className={error ? fieldsetErrorClass : fieldsetClass}>
|
||||
<legend className={showLegend ? 'Input_legend Input_legend___focus' : 'Input_legend'}>
|
||||
<FormattedMessage
|
||||
id='payment.card_number'
|
||||
defaultMessage='Card Number'
|
||||
/>
|
||||
</legend>
|
||||
<CardElement
|
||||
{...otherProps}
|
||||
options={CARD_ELEMENT_OPTIONS}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</fieldset>
|
||||
{this.renderError(error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const InjectedCardInput = (props: OwnProps) => {
|
||||
return (
|
||||
<ElementsConsumer>
|
||||
{({elements}) => (
|
||||
<CardInput
|
||||
ref={props.forwardedRef}
|
||||
elements={elements}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</ElementsConsumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default InjectedCardInput;
|
||||
@@ -0,0 +1,186 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
.gatherIntent {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&__title {
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 24px !important;
|
||||
}
|
||||
|
||||
&__button {
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--button-bg) !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 400 !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.savedFeedback__text {
|
||||
align-self: center;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.AltPaymentsModal {
|
||||
.modal-content {
|
||||
max-width: 512px;
|
||||
max-height: 465px;
|
||||
background: var(--center-channel-bg);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
min-width: 532px;
|
||||
margin: auto;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
padding-right: 32px;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.AltPaymentsModal__header {
|
||||
align-items: baseline;
|
||||
border: none;
|
||||
|
||||
&.modal-header {
|
||||
background: var(--center-channel-bg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.icon-close {
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin-left: auto;
|
||||
background: var(--center-channel-bg);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 0 32px;
|
||||
margin-bottom: 24px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
}
|
||||
|
||||
&__submitted-icon-container {
|
||||
margin-bottom: 24px;
|
||||
|
||||
> svg {
|
||||
width: 51px;
|
||||
height: 51px;
|
||||
align-self: center;
|
||||
color: rgba(61, 184, 135, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
text-align: center;
|
||||
|
||||
&__question {
|
||||
margin-bottom: 10px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__option {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__label {
|
||||
padding-left: 12px;
|
||||
cursor: default;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
&__text {
|
||||
color: var(--error-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
filter: invert(54%) sepia(68%) saturate(314%) hue-rotate(309deg) brightness(83%) contrast(117%);
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
border: solid 1px rgba(63, 67, 80, 0.16);
|
||||
background: rgb(var(--center-channel-bg-rgb));
|
||||
border-radius: 4px;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
&--secondary {
|
||||
@include tertiary-button;
|
||||
@include button-medium;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
@include primary-button;
|
||||
@include button-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent, screen, act} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import * as reactRedux from 'react-redux';
|
||||
|
||||
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import {GatherIntent} from './gather_intent';
|
||||
import {GatherIntentModalProps} from './gather_intent_modal';
|
||||
|
||||
const DummyModal = ({onClose, onSave}: GatherIntentModalProps) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id='closeIcon'
|
||||
className='icon icon-close'
|
||||
aria-label='Close'
|
||||
title='Close'
|
||||
onClick={onClose}
|
||||
/>
|
||||
<p>{'Body'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSave({ach: true, other: false, wire: true});
|
||||
}}
|
||||
type='button'
|
||||
>
|
||||
{'Test'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
describe('components/gather_intent/gather_intent.tsx', () => {
|
||||
const gatherIntentText = 'gatherIntentText';
|
||||
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatchMock.mockClear();
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
entities: {
|
||||
cloud: {
|
||||
customer: TestHelper.getCloudCustomerMock(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
//Any because renderWithIntlAndStore doesn't have the store parameter typed as deep partial.
|
||||
const renderComponent = ({store}: {store: any} = {store: initialState}) => {
|
||||
return renderWithIntlAndStore(
|
||||
<GatherIntent
|
||||
modalComponent={DummyModal}
|
||||
gatherIntentText={gatherIntentText}
|
||||
typeGatherIntent='monthlySubscription'
|
||||
/>,
|
||||
store,
|
||||
);
|
||||
};
|
||||
|
||||
it('should display modal if the user click on the modal opener', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the modal opener after close the modal', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
|
||||
expect(screen.queryByText('Body')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal after save the configuration', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Test'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal after save the configuration and reopening the modal', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Test'));
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Done'));
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal when the user has a feedback recorded', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
const newState = JSON.parse(JSON.stringify(initialState));
|
||||
newState.entities.cloud.customer = {
|
||||
...newState.entities.cloud.customer,
|
||||
monthly_subscription_alt_payment_method: 'Dummy feedback',
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
store: newState,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {JSXElementConstructor} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
|
||||
import {TypePurchases} from '@mattermost/types/cloud';
|
||||
|
||||
import {GatherIntentModalProps} from './gather_intent_modal';
|
||||
import {GatherIntentSubmittedModal} from './gather_intent_submitted_modal';
|
||||
import {useGatherIntent} from './useGatherIntent';
|
||||
import './gather_intent.scss';
|
||||
|
||||
interface GatherIntentProps {
|
||||
typeGatherIntent: keyof typeof TypePurchases;
|
||||
gatherIntentText: React.ReactNode;
|
||||
modalComponent: JSXElementConstructor<GatherIntentModalProps>;
|
||||
}
|
||||
|
||||
export const GatherIntent = ({gatherIntentText, typeGatherIntent, modalComponent: ModalComponent}: GatherIntentProps) => {
|
||||
const {
|
||||
feedbackSaved,
|
||||
handleSaveFeedback,
|
||||
showModal,
|
||||
handleOpenModal,
|
||||
handleCloseModal,
|
||||
submittingFeedback,
|
||||
showError,
|
||||
} = useGatherIntent({typeGatherIntent});
|
||||
|
||||
return (
|
||||
<div className='gatherIntent'>
|
||||
<FormattedMessage
|
||||
id={'payment_form.gather_wire_transfer_intent_title'}
|
||||
defaultMessage='Alternate Payment Options'
|
||||
>
|
||||
{(text) => (
|
||||
<h3 className='gatherIntent__title'>
|
||||
{text}
|
||||
</h3>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
<button
|
||||
className={'gatherIntent__button'}
|
||||
id={typeGatherIntent}
|
||||
onClick={handleOpenModal}
|
||||
type='button'
|
||||
>
|
||||
{gatherIntentText}
|
||||
</button>
|
||||
{showModal &&
|
||||
<Modal
|
||||
className='AltPaymentsModal'
|
||||
dialogClassName='a11y__modal'
|
||||
show={showModal}
|
||||
onHide={handleCloseModal}
|
||||
onExited={handleCloseModal}
|
||||
role='dialog'
|
||||
id='AltPaymentsModal'
|
||||
aria-modal='true'
|
||||
>
|
||||
{!feedbackSaved &&
|
||||
<ModalComponent
|
||||
onSave={handleSaveFeedback}
|
||||
onClose={handleCloseModal}
|
||||
isSubmitting={submittingFeedback}
|
||||
showError={showError}
|
||||
/>}
|
||||
{feedbackSaved &&
|
||||
<GatherIntentSubmittedModal onClose={handleCloseModal}/>}
|
||||
</Modal>}
|
||||
</div>);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent, screen} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithIntl} from 'tests/react_testing_utils';
|
||||
|
||||
import {GatherIntentModal, GatherIntentModalProps} from './gather_intent_modal';
|
||||
|
||||
describe('components/gather_intent/gather_intent_modal.tsx', () => {
|
||||
const renderComponent = (props: Partial<GatherIntentModalProps> | undefined = {}) => {
|
||||
const defaultProps: GatherIntentModalProps = {
|
||||
onClose: jest.fn(),
|
||||
onSave: jest.fn(),
|
||||
isSubmitting: false,
|
||||
showError: false,
|
||||
};
|
||||
|
||||
return renderWithIntl(
|
||||
<GatherIntentModal
|
||||
{...defaultProps}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
it('shouldn\'t be able to save the feedback if the user don\'t click on any option', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByText('Save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shouldn\'t be able to save the feedback if the user only click in other and leave the input empty', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText('Other'));
|
||||
|
||||
expect(screen.queryByText('Save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shouldn\'t be able to save the feedback if the user only click in other and write only white spaces in the input', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText('Other'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter payment option here'), {target: {value: ' \n\t'}});
|
||||
|
||||
expect(screen.queryByText('Save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be able to save the feedback if the user only click in other, leave the input empty and press other option', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText('Other'));
|
||||
fireEvent.click(screen.getByText('Wire'));
|
||||
|
||||
expect(screen.queryByText('Save')).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should be able save the feedback if the user click in Wire option', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText('Wire'));
|
||||
|
||||
expect(screen.queryByText('Save')).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should be able save the feedback if the user click in ACH option', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText('ACH'));
|
||||
|
||||
expect(screen.queryByText('Save')).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should be able save the feedback if the user click in other option and fill the option', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByText('Other'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter payment option here'), {target: {value: 'Test'}});
|
||||
|
||||
expect(screen.queryByText('Save')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
|
||||
import warningIcon from 'images/icons/warning-icon.svg';
|
||||
|
||||
import './gather_intent.scss';
|
||||
import {FormDataState} from './useGatherIntent';
|
||||
|
||||
export interface GatherIntentModalProps {
|
||||
onClose: () => void;
|
||||
onSave: (formData: FormDataState) => void;
|
||||
isSubmitting: boolean;
|
||||
showError: boolean;
|
||||
}
|
||||
|
||||
const isOtherUnchecked = (name: string, value: boolean): boolean => {
|
||||
return name === 'other' && value === false;
|
||||
};
|
||||
|
||||
const isOtherChecked = (name: string, value: boolean): boolean => {
|
||||
return name === 'other' && value === true;
|
||||
};
|
||||
|
||||
const isEmptyInput = (value: undefined | string) => {
|
||||
return value == null || value.trim() === '';
|
||||
};
|
||||
|
||||
const isFormEmpty = (formDataState: FormDataState) => {
|
||||
if (formDataState.other) {
|
||||
return isEmptyInput(formDataState.otherPaymentOption) && !formDataState.wire && !formDataState.ach;
|
||||
}
|
||||
|
||||
return Object.values(formDataState).every((value) => value === false || value == null);
|
||||
};
|
||||
|
||||
export const GatherIntentModal = ({onClose, onSave, isSubmitting, showError}: GatherIntentModalProps) => {
|
||||
const [formState, setFormState] = useState<FormDataState>({
|
||||
ach: false,
|
||||
wire: false,
|
||||
other: false,
|
||||
otherPaymentOption: undefined,
|
||||
});
|
||||
const intl = useIntl();
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onSave(formState);
|
||||
};
|
||||
|
||||
const handleTextAreaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const {name, value} = event.target;
|
||||
|
||||
setFormState((formDataState) => ({
|
||||
...formDataState,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
const handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const {name, checked} = event.target;
|
||||
|
||||
if (isOtherUnchecked(name, checked)) {
|
||||
setFormState((formDataState) => ({
|
||||
...formDataState,
|
||||
other: false,
|
||||
otherPaymentOption: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isOtherChecked(name, checked)) {
|
||||
setFormState((formDataState) => ({
|
||||
...formDataState,
|
||||
other: true,
|
||||
otherPaymentOption: '',
|
||||
}));
|
||||
}
|
||||
|
||||
setFormState((formDataState) => ({
|
||||
...formDataState,
|
||||
[name]: checked,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className='AltPaymentsModal__header '>
|
||||
<FormattedMessage
|
||||
id={'payment_form.gather_wire_transfer_intent_title'}
|
||||
defaultMessage='Alternate Payment Options'
|
||||
>
|
||||
{(text) => (
|
||||
<h3 className='Form-section-title'>
|
||||
{text}
|
||||
</h3>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
<button
|
||||
id='closeIcon'
|
||||
className='icon icon-close'
|
||||
aria-label='Close'
|
||||
title='Close'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<form
|
||||
id='gather_intent_wire_transfer'
|
||||
className='Form'
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.question'
|
||||
defaultMessage='Which payment options are you interested in using?'
|
||||
>
|
||||
{(text) => <p className='AltPaymentsModal__body__question'>{text}</p>}
|
||||
</FormattedMessage>
|
||||
<div className='Form-checkbox AltPaymentsModal__body__option'>
|
||||
<input
|
||||
className='AltPaymentsModal__body__checkbox'
|
||||
id='wire'
|
||||
name='wire'
|
||||
type='checkbox'
|
||||
checked={formState.wire}
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.wire'
|
||||
defaultMessage='Wire'
|
||||
>
|
||||
{(text) => (
|
||||
<label
|
||||
className='AltPaymentsModal__body__label'
|
||||
htmlFor='wire'
|
||||
>
|
||||
{text}
|
||||
</label>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
<div className='AltPaymentsModal__body__option'>
|
||||
<input
|
||||
className='AltPaymentsModal__body__checkbox'
|
||||
id='ach'
|
||||
name='ach'
|
||||
type='checkbox'
|
||||
checked={formState.ach}
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.ach'
|
||||
defaultMessage='ACH'
|
||||
>
|
||||
{(text) => (
|
||||
<label
|
||||
className='AltPaymentsModal__body__label'
|
||||
htmlFor='ach'
|
||||
>
|
||||
{text}
|
||||
</label>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
<div className='AltPaymentsModal__body__option'>
|
||||
<input
|
||||
className='AltPaymentsModal__body__checkbox'
|
||||
id='other'
|
||||
name='other'
|
||||
type='checkbox'
|
||||
checked={formState.other}
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.other'
|
||||
defaultMessage='Other'
|
||||
>
|
||||
{(text) => (
|
||||
<label
|
||||
className='AltPaymentsModal__body__label'
|
||||
htmlFor='other'
|
||||
>
|
||||
{text}
|
||||
</label>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
{formState.other && <div className='AltPaymentsModal__body__option'>
|
||||
<textarea
|
||||
id='other-payment-option'
|
||||
name='otherPaymentOption'
|
||||
className='AltPaymentsModal__body__textarea'
|
||||
value={formState.otherPaymentOption}
|
||||
onChange={handleTextAreaChange}
|
||||
placeholder={intl.formatMessage({id: 'payment_form.gather_wire_transfer_intent_modal.otherPaymentOptionPlaceholder', defaultMessage: 'Enter payment option here'})}
|
||||
rows={2}
|
||||
maxLength={400}
|
||||
/>
|
||||
</div>}
|
||||
{showError &&
|
||||
<div className='AltPaymentsModal__body__error'>
|
||||
<div>
|
||||
<img
|
||||
className='AltPaymentsModal__body__error__icon'
|
||||
alt=''
|
||||
src={warningIcon}
|
||||
/>
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='gather_intent.error_feedback'
|
||||
defaultMessage='Sorry, there was an error sending feedback. Please try again.'
|
||||
>
|
||||
{(text) => <span className='AltPaymentsModal__body__error__text'>{text}</span>}
|
||||
</FormattedMessage>
|
||||
</div>}
|
||||
|
||||
</form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className={'AltPaymentsModal__footer '}>
|
||||
<button
|
||||
className={'AltPaymentsModal__footer--secondary'}
|
||||
id={'cancelFeedback'}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={'AltPaymentsModal__footer--primary'}
|
||||
id={'submitFeedback'}
|
||||
type='submit'
|
||||
form='gather_intent_wire_transfer'
|
||||
disabled={isFormEmpty(formState) || isSubmitting}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.save'
|
||||
defaultMessage='Save'
|
||||
/>
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</>);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// 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 {Modal} from 'react-bootstrap';
|
||||
|
||||
import './gather_intent.scss';
|
||||
import {CheckCircleIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
export interface GatherIntentModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const GatherIntentSubmittedModal = ({onClose}: GatherIntentModalProps) => {
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className='AltPaymentsModal__header '>
|
||||
<button
|
||||
id='closeIcon'
|
||||
className='icon icon-close'
|
||||
aria-label='Close'
|
||||
title='Close'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Modal.Header>
|
||||
<Modal.Body className='AltPaymentsModal__body'>
|
||||
<div className='AltPaymentsModal__submitted-icon-container'>
|
||||
<CheckCircleIcon/>
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='gather_intent.feedback_saved'
|
||||
defaultMessage='Thanks for sharing feedback!'
|
||||
>
|
||||
{(text) => <span className='savedFeedback__text'>{text}</span>}
|
||||
</FormattedMessage>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className={'AltPaymentsModal__footer '}>
|
||||
<button
|
||||
className={'AltPaymentsModal__footer--primary'}
|
||||
id={'feedbackSubmitedDone'}
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='generic.done'
|
||||
defaultMessage='Done'
|
||||
/>
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</>);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {GatherIntent} from './gather_intent';
|
||||
export {GatherIntentModal} from './gather_intent_modal';
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useState, useEffect} from 'react';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {MetadataGatherWireTransferKeys, TypePurchases} from '@mattermost/types/cloud';
|
||||
import {updateCloudCustomer} from 'mattermost-redux/actions/cloud';
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {GlobalState} from 'types/store';
|
||||
|
||||
interface UseGatherIntentArgs {
|
||||
typeGatherIntent: keyof typeof TypePurchases;
|
||||
}
|
||||
|
||||
export type FormDataState = FormDateStateWithoutOtherPayment | FormDateStateWithOtherPayment;
|
||||
|
||||
interface FormDateStateWithOtherPayment {
|
||||
wire: boolean;
|
||||
ach: boolean;
|
||||
other: true;
|
||||
otherPaymentOption: string;
|
||||
}
|
||||
|
||||
interface FormDateStateWithoutOtherPayment {
|
||||
wire: boolean;
|
||||
ach: boolean;
|
||||
other: false;
|
||||
otherPaymentOption?: never;
|
||||
}
|
||||
|
||||
export const useGatherIntent = ({typeGatherIntent}: UseGatherIntentArgs) => {
|
||||
const dispatch = useDispatch<any>();
|
||||
const [feedbackSaved, setFeedbackSave] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [submittingFeedback, setSubmittingFeedback] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const customer = useSelector((state: GlobalState) => state.entities.cloud.customer);
|
||||
|
||||
const handleSaveFeedback = async (formData: FormDataState) => {
|
||||
setSubmittingFeedback(() => true);
|
||||
|
||||
const gatherIntentKey: MetadataGatherWireTransferKeys = `${TypePurchases[typeGatherIntent]}_alt_payment_method`;
|
||||
|
||||
const {error} = await dispatch(updateCloudCustomer({
|
||||
[gatherIntentKey]: JSON.stringify(formData),
|
||||
}));
|
||||
|
||||
if (error == null) {
|
||||
setFeedbackSave(() => true);
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
setShowError(() => true);
|
||||
}
|
||||
|
||||
setSubmittingFeedback(() => false);
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
trackEvent('click_open_payment_feedback_form_modal', {
|
||||
location: `${TypePurchases[typeGatherIntent]}_form`,
|
||||
});
|
||||
setShowModal(() => true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(() => false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customer != null) {
|
||||
const gatherIntentKey: MetadataGatherWireTransferKeys = `${TypePurchases[typeGatherIntent]}_alt_payment_method`;
|
||||
setFeedbackSave(Boolean(customer[gatherIntentKey]));
|
||||
}
|
||||
}, [customer, typeGatherIntent]);
|
||||
|
||||
return {feedbackSaved, handleSaveFeedback, handleOpenModal, showModal, handleCloseModal, submittingFeedback, showError};
|
||||
};
|
||||
168
webapp/channels/src/components/payment_form/payment_form.scss
Обычный файл
168
webapp/channels/src/components/payment_form/payment_form.scss
Обычный файл
@@ -0,0 +1,168 @@
|
||||
.PaymentForm {
|
||||
padding-right: 96px;
|
||||
padding-left: 96px;
|
||||
margin: 0 auto;
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.form-row-third-1 {
|
||||
.DropdownInput {
|
||||
z-index: 99999;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
width: 66%;
|
||||
max-width: 288px;
|
||||
margin-right: 16px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.form-row-third-2 {
|
||||
width: 34%;
|
||||
max-width: 144px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 24px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.DropdownInput {
|
||||
position: relative;
|
||||
z-index: 999999;
|
||||
height: 36px;
|
||||
margin-bottom: 24px;
|
||||
font-weight: normal;
|
||||
|
||||
.DropDown__control {
|
||||
min-height: 0;
|
||||
background-color: var(--center-channel-bg) !important;
|
||||
|
||||
.DropDown__value-container > div {
|
||||
color: var(--center-channel-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.DropDown__menu {
|
||||
background-color: var(--center-channel-bg) !important;
|
||||
box-shadow: 0 0 0 1px var(--center-channel-color), 0 4px 11px var(--center-channel-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
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;
|
||||
-webkit-text-fill-color: var(--center-channel-color) !important;
|
||||
}
|
||||
|
||||
.Input_fieldset {
|
||||
height: 40px;
|
||||
padding: 2px 1px;
|
||||
background: var(--center-channel-bg);
|
||||
|
||||
.Input_wrapper {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.Input_fieldset___legend {
|
||||
>legend {
|
||||
margin-left: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&.Input_fieldset:focus-within {
|
||||
padding-top: 2px;
|
||||
box-shadow: inset 0 0 0 2px var(--button-bg);
|
||||
color: var(--button-bg);
|
||||
}
|
||||
|
||||
&.Input_fieldset___error {
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
box-shadow: inset 0 0 0 1px var(--error-text);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
&.Input_fieldset___error:focus-within {
|
||||
box-shadow: inset 0 0 0 2px var(--error-text);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.Input {
|
||||
height: 32px;
|
||||
background: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--center-channel-color);
|
||||
opacity: 0.64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.PaymentForm-saved {
|
||||
width: 442px;
|
||||
height: fit-content;
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.PaymentForm-saved-title {
|
||||
margin-bottom: 16px;
|
||||
color: var(--secondary-blue);
|
||||
font-family: Metropolis;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.5px;
|
||||
line-height: 18px;
|
||||
opacity: 0.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.PaymentForm-saved-card {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
color: var(--secondary-blue);
|
||||
font-family: Source Sans Pro;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.PaymentForm-saved-address {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--secondary-blue);
|
||||
font-family: Source Sans Pro;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.PaymentForm-change {
|
||||
color: #0058cc;
|
||||
font-family: Source Sans Pro;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
381
webapp/channels/src/components/payment_form/payment_form.tsx
Обычный файл
381
webapp/channels/src/components/payment_form/payment_form.tsx
Обычный файл
@@ -0,0 +1,381 @@
|
||||
// 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 {
|
||||
StripeCardElementChangeEvent,
|
||||
} from '@stripe/stripe-js';
|
||||
|
||||
import {PaymentMethod} from '@mattermost/types/cloud';
|
||||
|
||||
import {BillingDetails} from 'types/cloud/sku';
|
||||
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import DropdownInput from 'components/dropdown_input';
|
||||
import Input from 'components/widgets/inputs/input/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 {GatherIntent, GatherIntentModal} from './gather_intent';
|
||||
|
||||
import './payment_form.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
initialBillingDetails?: BillingDetails;
|
||||
paymentMethod?: PaymentMethod;
|
||||
theme: Theme;
|
||||
onCardInputChange?: (change: StripeCardElementChangeEvent) => void;
|
||||
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<Props, State> {
|
||||
static defaultProps = {
|
||||
showSaveCard: false,
|
||||
className: '',
|
||||
};
|
||||
|
||||
cardRef: React.RefObject<CardInputType>;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.cardRef = React.createRef<CardInputType>();
|
||||
|
||||
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<HTMLInputElement> | React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const target = event.target;
|
||||
const name = target.name;
|
||||
const value = target.value;
|
||||
|
||||
const newStateValue = {
|
||||
[name]: value,
|
||||
} as unknown as Pick<State, keyof State>;
|
||||
|
||||
this.setState(newStateValue);
|
||||
|
||||
const {onInputChange} = this.props;
|
||||
if (onInputChange) {
|
||||
onInputChange({...this.state, ...newStateValue, card: this.cardRef.current?.getCard()} as BillingDetails);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCardInputChange = (event: StripeCardElementChangeEvent) => {
|
||||
if (this.props.onCardInputChange) {
|
||||
this.props.onCardInputChange(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStateChange = (stateValue: string) => {
|
||||
const newStateValue = {
|
||||
state: stateValue,
|
||||
} as unknown as Pick<State, keyof State>;
|
||||
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<State, keyof State>;
|
||||
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<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
this.setState({changePaymentMethod: true});
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {className, paymentMethod, buttonFooter, theme} = this.props;
|
||||
const {changePaymentMethod} = this.state;
|
||||
|
||||
let paymentDetails: JSX.Element;
|
||||
if (changePaymentMethod) {
|
||||
paymentDetails = (
|
||||
<React.Fragment>
|
||||
<div className='form-row'>
|
||||
<CardInput
|
||||
forwardedRef={this.cardRef}
|
||||
required={true}
|
||||
onBlur={this.onBlur}
|
||||
onCardInputChange={this.handleCardInputChange}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='name'
|
||||
type='text'
|
||||
value={this.state.name}
|
||||
onChange={this.handleInputChange}
|
||||
onBlur={this.onBlur}
|
||||
placeholder={Utils.localizeMessage(
|
||||
'payment_form.name_on_card',
|
||||
'Name on Card',
|
||||
)}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='section-title'>
|
||||
<FormattedMessage
|
||||
id='payment_form.billing_address'
|
||||
defaultMessage='Billing address'
|
||||
/>
|
||||
</div>
|
||||
<DropdownInput
|
||||
onChange={this.handleCountryChange}
|
||||
value={
|
||||
this.state.country ? {value: this.state.country, label: this.state.country} : undefined
|
||||
}
|
||||
options={COUNTRIES.map((country) => ({
|
||||
value: country.name,
|
||||
label: country.name,
|
||||
}))}
|
||||
legend={Utils.localizeMessage(
|
||||
'payment_form.country',
|
||||
'Country',
|
||||
)}
|
||||
placeholder={Utils.localizeMessage(
|
||||
'payment_form.country',
|
||||
'Country',
|
||||
)}
|
||||
name={'billing_dropdown'}
|
||||
/>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='address'
|
||||
type='text'
|
||||
value={this.state.address}
|
||||
onChange={this.handleInputChange}
|
||||
onBlur={this.onBlur}
|
||||
placeholder={Utils.localizeMessage(
|
||||
'payment_form.address',
|
||||
'Address',
|
||||
)}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='address2'
|
||||
type='text'
|
||||
value={this.state.address2}
|
||||
onChange={this.handleInputChange}
|
||||
onBlur={this.onBlur}
|
||||
placeholder={Utils.localizeMessage(
|
||||
'payment_form.address_2',
|
||||
'Address 2',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='city'
|
||||
type='text'
|
||||
value={this.state.city}
|
||||
onChange={this.handleInputChange}
|
||||
onBlur={this.onBlur}
|
||||
placeholder={Utils.localizeMessage(
|
||||
'payment_form.city',
|
||||
'City',
|
||||
)}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<div className='form-row-third-1 selector'>
|
||||
<StateSelector
|
||||
country={this.state.country}
|
||||
state={this.state.state}
|
||||
onChange={this.handleStateChange}
|
||||
onBlur={this.onBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row-third-2'>
|
||||
<Input
|
||||
name='postalCode'
|
||||
type='text'
|
||||
value={this.state.postalCode}
|
||||
onChange={this.handleInputChange}
|
||||
onBlur={this.onBlur}
|
||||
placeholder={Utils.localizeMessage(
|
||||
'payment_form.zipcode',
|
||||
'Zip/Postal Code',
|
||||
)}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{changePaymentMethod ? buttonFooter : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
let cardContent: JSX.Element | null = null;
|
||||
|
||||
if (paymentMethod) {
|
||||
let cardDetails = (
|
||||
<FormattedMessage
|
||||
id='payment_form.no_credit_card'
|
||||
defaultMessage='No credit card added'
|
||||
/>
|
||||
);
|
||||
if (paymentMethod.last_four) {
|
||||
cardDetails = (
|
||||
<React.Fragment>
|
||||
<CardImage brand={paymentMethod.card_brand}/>
|
||||
{`Card ending in ${paymentMethod.last_four}`}
|
||||
<br/>
|
||||
{`Expires ${paymentMethod.exp_month}/${paymentMethod.exp_year}`}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
let addressDetails = (
|
||||
<i>
|
||||
<FormattedMessage
|
||||
id='payment_form.no_billing_address'
|
||||
defaultMessage='No billing address added'
|
||||
/>
|
||||
</i>);
|
||||
if (this.state.state) {
|
||||
addressDetails = (
|
||||
<React.Fragment>
|
||||
{this.state.address}
|
||||
{this.state.address2}
|
||||
<br/>
|
||||
{`${this.state.city}, ${this.state.state}, ${this.state.country}`}
|
||||
<br/>
|
||||
{this.state.postalCode}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
cardContent = (
|
||||
<React.Fragment>
|
||||
<div className='PaymentForm-saved-card'>
|
||||
{cardDetails}
|
||||
</div>
|
||||
<div className='PaymentForm-saved-address'>
|
||||
{addressDetails}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
paymentDetails = (
|
||||
<div
|
||||
id='console_payment_saved'
|
||||
className='PaymentForm-saved'
|
||||
>
|
||||
<div className='PaymentForm-saved-title'>
|
||||
<FormattedMessage
|
||||
id='payment_form.saved_payment_method'
|
||||
defaultMessage='Saved Payment Method'
|
||||
/>
|
||||
</div>
|
||||
{cardContent}
|
||||
<button
|
||||
className='Form-btn-link PaymentForm-change'
|
||||
onClick={this.changePaymentMethod}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='payment_form.change_payment_method'
|
||||
defaultMessage='Change Payment Method'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id='payment_form'
|
||||
className={`PaymentForm ${className}`}
|
||||
>
|
||||
<GatherIntent
|
||||
typeGatherIntent='monthlySubscription'
|
||||
modalComponent={GatherIntentModal}
|
||||
gatherIntentText={
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent'
|
||||
defaultMessage='Looking for other payment options?'
|
||||
/>}
|
||||
/>
|
||||
<div className='section-title'>
|
||||
<FormattedMessage
|
||||
id='payment_form.credit_card'
|
||||
defaultMessage='Credit Card'
|
||||
/>
|
||||
</div>
|
||||
{paymentDetails}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
73
webapp/channels/src/components/payment_form/state_selector.tsx
Обычный файл
73
webapp/channels/src/components/payment_form/state_selector.tsx
Обычный файл
@@ -0,0 +1,73 @@
|
||||
// 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/widgets/inputs/input/input';
|
||||
|
||||
import {US_STATES, CA_PROVINCES, StateCode} from 'utils/states';
|
||||
|
||||
type Props = {
|
||||
country: string;
|
||||
state: string;
|
||||
testId?: 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) {
|
||||
const withId: {testId?: string} = {};
|
||||
if (props.testId) {
|
||||
withId.testId = props.testId;
|
||||
}
|
||||
return (
|
||||
<DropdownInput
|
||||
{...withId}
|
||||
onChange={onStateSelected}
|
||||
value={props.state ? {value: props.state, label: props.state} : undefined}
|
||||
options={stateList.map((stateCode) => ({
|
||||
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 (
|
||||
<Input
|
||||
name='state'
|
||||
type='text'
|
||||
value={props.state}
|
||||
onChange={(e) => {
|
||||
props.onChange(e.target.value);
|
||||
}}
|
||||
onBlur={props.onBlur}
|
||||
placeholder={formatMessage({id: 'admin.billing.subscription.stateprovince', defaultMessage: 'State/Province'})}
|
||||
required={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
28
webapp/channels/src/components/payment_form/stripe.ts
Обычный файл
28
webapp/channels/src/components/payment_form/stripe.ts
Обычный файл
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* 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);
|
||||
|
||||
export const STRIPE_CSS_SRC = 'https://fonts.googleapis.com/css?family=Open+Sans:400,400i,600,600i&display=swap';
|
||||
export const STRIPE_PUBLIC_KEY = 'pk_test_ttEpW6dCHksKyfAFzh6MvgBj';
|
||||
Ссылка в новой задаче
Block a user