Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -0,0 +1,8 @@
.CardImage {
width: auto;
max-width: 30px;
height: auto;
max-height: 30px;
margin-top: -1px;
margin-right: 9px;
}

Просмотреть файл

@@ -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 '';
}

Просмотреть файл

@@ -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);
}

Просмотреть файл

@@ -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};
};

Просмотреть файл

@@ -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;
}
}

Просмотреть файл

@@ -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>
);
}
}

Просмотреть файл

@@ -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}
/>
);
}

Просмотреть файл

@@ -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';