diff --git a/src/forms/StripePaymentForm/StripePaymentAddress.js b/src/forms/StripePaymentForm/StripePaymentAddress.js new file mode 100644 index 00000000..14dbe1f2 --- /dev/null +++ b/src/forms/StripePaymentForm/StripePaymentAddress.js @@ -0,0 +1,186 @@ +import React from 'react'; +import { intlShape } from 'react-intl'; +import { bool, object, string } from 'prop-types'; +import config from '../../config'; +import * as validators from '../../util/validators'; +import getCountryCodes from '../../translations/countryCodes'; +import { FieldTextInput, FieldSelect } from '../../components'; + +import css from './StripePaymentForm.css'; + +const StripePaymentAddress = props => { + const { className, intl, disabled, form, fieldId, card } = props; + + const optionalText = intl.formatMessage({ + id: 'StripePaymentAddress.optionalText', + }); + + const addressLine1Label = intl.formatMessage({ + id: 'StripePaymentAddress.addressLine1Label', + }); + const addressLine1Placeholder = intl.formatMessage({ + id: 'StripePaymentAddress.addressLine1Placeholder', + }); + const addressLine1Required = validators.required( + intl.formatMessage({ + id: 'StripePaymentAddress.addressLine1Required', + }) + ); + + const addressLine2Label = intl.formatMessage( + { id: 'StripePaymentAddress.addressLine2Label' }, + { optionalText: optionalText } + ); + + const addressLine2Placeholder = intl.formatMessage({ + id: 'StripePaymentAddress.addressLine2Placeholder', + }); + + const postalCodeLabel = intl.formatMessage({ id: 'StripePaymentAddress.postalCodeLabel' }); + const postalCodePlaceholder = intl.formatMessage({ + id: 'StripePaymentAddress.postalCodePlaceholder', + }); + const postalCodeRequired = validators.required( + intl.formatMessage({ + id: 'StripePaymentAddress.postalCodeRequired', + }) + ); + + const cityLabel = intl.formatMessage({ id: 'StripePaymentAddress.cityLabel' }); + const cityPlaceholder = intl.formatMessage({ id: 'StripePaymentAddress.cityPlaceholder' }); + const cityRequired = validators.required( + intl.formatMessage({ + id: 'StripePaymentAddress.cityRequired', + }) + ); + + const stateLabel = intl.formatMessage( + { id: 'StripePaymentAddress.stateLabel' }, + { optionalText: optionalText } + ); + const statePlaceholder = intl.formatMessage({ id: 'StripePaymentAddress.statePlaceholder' }); + + const countryLabel = intl.formatMessage({ id: 'StripePaymentAddress.countryLabel' }); + const countryPlaceholder = intl.formatMessage({ id: 'StripePaymentAddress.countryPlaceholder' }); + const countryRequired = validators.required( + intl.formatMessage({ + id: 'StripePaymentAddress.countryRequired', + }) + ); + + const handleOnChange = event => { + const value = event.target.value; + form.change('postal', value); + card.update({ value: { postalCode: value } }); + }; + + // Use tha language set in config.locale to get the correct translations of the country names + const countryCodes = getCountryCodes(config.locale); + + return ( +
+ form.change('addressLine1', undefined)} + /> + + form.change('addressLine2', undefined)} + /> + +
+ form.change('postal', undefined)} + onChange={event => handleOnChange(event)} + /> + + form.change('city', undefined)} + /> +
+ + form.change('state', undefined)} + /> + + + + {countryCodes.map(country => { + return ( + + ); + })} + +
+ ); +}; +StripePaymentAddress.defaultProps = { + country: null, + disabled: false, + fieldId: null, +}; + +StripePaymentAddress.propTypes = { + country: string, + disabled: bool, + form: object.isRequired, + fieldId: string, + + // from injectIntl + intl: intlShape.isRequired, +}; + +export default StripePaymentAddress; diff --git a/src/forms/StripePaymentForm/StripePaymentForm.css b/src/forms/StripePaymentForm/StripePaymentForm.css index c24e4c63..786ef362 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.css +++ b/src/forms/StripePaymentForm/StripePaymentForm.css @@ -19,7 +19,7 @@ height: 35px; } @media (--viewportLarge) { - height: 42px; + height: 38px; padding: 6px 0 14px 0; } } @@ -32,10 +32,34 @@ border-bottom-color: var(--failColor); } +.error { + color: var(--failColor); +} + +.errorMessage { + margin-top: 24px; + color: var(--failColor); +} + .paymentHeading { margin: 0 0 14px 0; color: var(--matterColorAnti); + padding-top: 8px; + padding-bottom: 0px; + + @media (--viewportMedium) { + margin: 0 0 26px 0; + } +} + +.billingHeading { + margin: 0 0 14px 0; + color: var(--matterColorAnti); + + padding-top: 7px; + padding-bottom: 1px; + @media (--viewportMedium) { margin: 0 0 26px 0; } @@ -56,6 +80,9 @@ color: var(--matterColorAnti); margin: 40px 0 14px 0; + padding-top: 4px; + padding-bottom: 4px; + @media (--viewportMedium) { margin: 41px 0 26px 0; } @@ -116,3 +143,29 @@ .missingStripeKey { color: var(--failColor); } + +.paymentAddressField { + padding-top: 38px; +} + +.formRow { + display: flex; + justify-content: space-between; + flex-shrink: 0; + width: 100%; + margin-bottom: 24px; +} + +.postalCode { + margin-top: 24px; + width: calc(40% - 9px); +} + +.city { + margin-top: 24px; + width: calc(60% - 9px); +} + +.field { + margin-top: 24px; +} diff --git a/src/forms/StripePaymentForm/StripePaymentForm.example.js b/src/forms/StripePaymentForm/StripePaymentForm.example.js index 739a31e6..1ec25073 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.example.js +++ b/src/forms/StripePaymentForm/StripePaymentForm.example.js @@ -17,6 +17,7 @@ export const Empty = { }, intl: fakeIntl, onCreateStripePaymentToken: noop, + onStripeInitialized: noop, stripePaymentTokenInProgress: false, stripePaymentTokenError: null, }, diff --git a/src/forms/StripePaymentForm/StripePaymentForm.js b/src/forms/StripePaymentForm/StripePaymentForm.js index 3ad1f621..b9025536 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.js +++ b/src/forms/StripePaymentForm/StripePaymentForm.js @@ -4,14 +4,13 @@ * It's also handled separately in handleSubmit function. */ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import { bool, func, object, string } from 'prop-types'; import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { Form as FinalForm } from 'react-final-form'; import classNames from 'classnames'; import config from '../../config'; -import { propTypes } from '../../util/types'; import { Form, PrimaryButton, FieldTextInput } from '../../components'; - +import StripePaymentAddress from './StripePaymentAddress'; import css from './StripePaymentForm.css'; /** @@ -78,19 +77,18 @@ const cardStyles = { const initialState = { error: null, - submitting: false, cardValueValid: false, - token: null, }; /** * Payment form that asks for credit card info using Stripe Elements. * * When the card is valid and the user submits the form, a request is - * sent to the Stripe API to fetch a token that is passed to the - * onSubmit prop of this form. + * sent to the Stripe API to handle payment. `stripe.handleCardPayment` + * may ask more details from cardholder if 3D security steps are needed. * - * See: https://stripe.com/docs/elements + * See: https://stripe.com/docs/payments/payment-intents + * https://stripe.com/docs/elements */ class StripePaymentForm extends Component { constructor(props) { @@ -99,6 +97,7 @@ class StripePaymentForm extends Component { this.handleCardValueChange = this.handleCardValueChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.paymentForm = this.paymentForm.bind(this); + this.finalFormAPI = null; } componentDidMount() { if (!window.Stripe) { @@ -107,18 +106,22 @@ class StripePaymentForm extends Component { if (config.stripe.publishableKey) { this.stripe = window.Stripe(config.stripe.publishableKey); - const elements = this.stripe.elements(stripeElementsOptions); - this.card = elements.create('card', { style: cardStyles }); - this.card.mount(this.cardContainer); - this.card.addEventListener('change', this.handleCardValueChange); - // EventListener is the only way to simulate breakpoints with Stripe. - window.addEventListener('resize', () => { - if (window.innerWidth < 1024) { - this.card.update({ style: { base: { fontSize: '18px', lineHeight: '24px' } } }); - } else { - this.card.update({ style: { base: { fontSize: '20px', lineHeight: '32px' } } }); - } - }); + this.props.onStripeInitialized(this.stripe); + + if (!this.props.hasHandledCardPayment) { + const elements = this.stripe.elements(stripeElementsOptions); + this.card = elements.create('card', { style: cardStyles }); + this.card.mount(this.cardContainer); + this.card.addEventListener('change', this.handleCardValueChange); + // EventListener is the only way to simulate breakpoints with Stripe. + window.addEventListener('resize', () => { + if (window.innerWidth < 1024) { + this.card.update({ style: { base: { fontSize: '18px', lineHeight: '24px' } } }); + } else { + this.card.update({ style: { base: { fontSize: '20px', lineHeight: '32px' } } }); + } + }); + } } } componentWillUnmount() { @@ -128,71 +131,90 @@ class StripePaymentForm extends Component { } } handleCardValueChange(event) { - const { intl, onChange } = this.props; + const { intl } = this.props; const { error, complete } = event; - // A change in the card should clear the token and trigger a call - // to the onChange prop with the cleared token and the current - // message. + const postalCode = event.value.postalCode; + if (this.finalFormAPI) { + this.finalFormAPI.change('postal', postalCode); + } this.setState(prevState => { - const { message } = prevState; - const token = null; - onChange({ token, message }); return { error: error ? stripeErrorTranslation(intl, error) : null, cardValueValid: complete, - token, }; }); } handleSubmit(values) { - const { onSubmit, stripePaymentTokenInProgress, stripePaymentToken } = this.props; - const initialMessage = values.initialMessage ? values.initialMessage.trim() : null; + const { onSubmit, inProgress, formId, hasHandledCardPayment } = this.props; + const { initialMessage } = values; + const cardInputNeedsAttention = !(hasHandledCardPayment || this.state.cardValueValid); - if (stripePaymentTokenInProgress || !this.state.cardValueValid) { + if (inProgress || cardInputNeedsAttention) { // Already submitting or card value incomplete/invalid return; } - if (stripePaymentToken) { - // Token already fetched for the current card value - onSubmit({ token: stripePaymentToken.id, message: initialMessage }); - return; - } - const params = { - stripe: this.stripe, + message: initialMessage ? initialMessage.trim() : null, card: this.card, + formId, + formValues: values, }; - - this.props.onCreateStripePaymentToken(params).then(() => { - onSubmit({ token: this.props.stripePaymentToken.id, message: initialMessage }); - }); + onSubmit(params); } paymentForm(formRenderProps) { const { className, rootClassName, - inProgress, + inProgress: submitInProgress, formId, paymentInfo, authorDisplayName, showInitialMessageInput, intl, - stripePaymentTokenInProgress, - stripePaymentTokenError, + initiateOrderError, + handleCardPaymentError, + confirmPaymentError, invalid, handleSubmit, + form, + hasHandledCardPayment, } = formRenderProps; - const submitInProgress = stripePaymentTokenInProgress || inProgress; - const submitDisabled = invalid || !this.state.cardValueValid || submitInProgress; + this.finalFormAPI = form; + const billingDetailsNeeded = !(confirmPaymentError || hasHandledCardPayment); + const cardInputNeedsAttention = !(hasHandledCardPayment || this.state.cardValueValid); + const submitDisabled = invalid || cardInputNeedsAttention || submitInProgress; + const hasCardError = this.state.error && !submitInProgress; + const hasPaymentErrors = handleCardPaymentError || confirmPaymentError; const classes = classNames(rootClassName || css.root, className); const cardClasses = classNames(css.card, { [css.cardSuccess]: this.state.cardValueValid, - [css.cardError]: stripePaymentTokenError && !submitInProgress, + [css.cardError]: hasCardError, + }); + + // TODO: handleCardPayment can create all kinds of errors. + // Currently, we provide translation support for one: + // https://stripe.com/docs/error-codes + const piAuthenticationFailure = 'payment_intent_authentication_failure'; + const paymentErrorMessage = + handleCardPaymentError && handleCardPaymentError.code === piAuthenticationFailure + ? intl.formatMessage({ id: 'StripePaymentForm.handleCardPaymentError' }) + : handleCardPaymentError + ? handleCardPaymentError.message + : confirmPaymentError + ? intl.formatMessage({ id: 'StripePaymentForm.confirmPaymentError' }) + : intl.formatMessage({ id: 'StripePaymentForm.genericError' }); + + const billingDetailsNameLabel = intl.formatMessage({ + id: 'StripePaymentForm.billingDetailsNameLabel', + }); + + const billingDetailsNamePlaceholder = intl.formatMessage({ + id: 'StripePaymentForm.billingDetailsNamePlaceholder', }); const messagePlaceholder = intl.formatMessage( @@ -209,43 +231,76 @@ class StripePaymentForm extends Component { { messageOptionalText: messageOptionalText } ); - const initialMessage = showInitialMessageInput ? ( -
-

- -

+ // Asking billing address is recommended in PaymentIntent flow. + // In CheckoutPage, we send name and email as billing details, but address only if it exists. + const billingAddress = ( + + ); - -
- ) : null; + const hasStripeKey = config.stripe.publishableKey; - return config.stripe.publishableKey ? ( + return hasStripeKey ? (
-

- -

- -
{ - this.cardContainer = el; - }} - /> - {this.state.error && !submitInProgress ? ( - {this.state.error} + {billingDetailsNeeded ? ( + +

+ +

+ + +
{ + this.cardContainer = el; + }} + /> + {hasCardError ? {this.state.error} : null} +
+

+ +

+ + + + {billingAddress} +
+ + ) : null} + + {initiateOrderError ? ( + {initiateOrderError.message} + ) : null} + {showInitialMessageInput ? ( +
+

+ +

+ + +
) : null} - {initialMessage}
+ {hasPaymentErrors ? ( + {paymentErrorMessage} + ) : null}

{paymentInfo}

- + {billingDetailsNeeded ? ( + + ) : ( + + )}
@@ -274,30 +333,27 @@ StripePaymentForm.defaultProps = { className: null, rootClassName: null, inProgress: false, - onChange: () => null, showInitialMessageInput: true, - stripePaymentToken: null, - stripePaymentTokenInProgress: false, - stripePaymentTokenError: null, + hasHandledCardPayment: false, + initiateOrderError: null, + handleCardPaymentError: null, + confirmPaymentError: null, }; -const { bool, func, string, object } = PropTypes; - StripePaymentForm.propTypes = { className: string, rootClassName: string, inProgress: bool, + initiateOrderError: object, + handleCardPaymentError: object, + confirmPaymentError: object, formId: string.isRequired, intl: intlShape.isRequired, onSubmit: func.isRequired, - onChange: func, paymentInfo: string.isRequired, authorDisplayName: string.isRequired, showInitialMessageInput: bool, - onCreateStripePaymentToken: func.isRequired, - stripePaymentTokenInProgress: bool, - stripePaymentTokenError: propTypes.error, - stripePaymentToken: object, + hasHandledCardPayment: bool, }; export default injectIntl(StripePaymentForm); diff --git a/src/translations/en.json b/src/translations/en.json index 3560fce8..b7d26113 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -778,8 +778,30 @@ "StripeBankAccountTokenInputField.transitNumber.placeholder": "Type in transit number…", "StripeBankAccountTokenInputField.transitNumber.required": "Transit number is required", "StripeBankAccountTokenInputField.unsupportedCountry": "Country not supported: {country}", + "StripePaymentAddress.addressLine1Label": "Street address", + "StripePaymentAddress.addressLine1Placeholder": "123 Example Street", + "StripePaymentAddress.addressLine1Required": "Street address is required", + "StripePaymentAddress.addressLine2Label": "Apt, suite, building # {optionalText}", + "StripePaymentAddress.addressLine2Placeholder": "A 42", + "StripePaymentAddress.cityLabel": "City", + "StripePaymentAddress.cityPlaceholder": "Helsinki", + "StripePaymentAddress.cityRequired": "City is required", + "StripePaymentAddress.countryLabel": "Country", + "StripePaymentAddress.countryPlaceholder": "Select a country…", + "StripePaymentAddress.countryRequired": "Country is required", + "StripePaymentAddress.optionalText": "• optional", + "StripePaymentAddress.postalCodeLabel": "Postal code", + "StripePaymentAddress.postalCodePlaceholder": "00100", + "StripePaymentAddress.postalCodeRequired": "Postal code is required", + "StripePaymentAddress.stateLabel": "State {optionalText}", + "StripePaymentAddress.statePlaceholder": "Enter your state", + "StripePaymentForm.billingDetails": "Billing details", + "StripePaymentForm.billingDetailsNameLabel": "Card holder's name", + "StripePaymentForm.billingDetailsNamePlaceholder": "Enter your name…", + "StripePaymentForm.confirmPaymentError": "Payment has been made but we were unable to confirm the booking. Please try to confirm the booking request again! If the booking is not confirmed in time, the payment will be fully refunded.", "StripePaymentForm.creditCardDetails": "Credit card details", "StripePaymentForm.genericError": "Could not handle payment data. Please try again.", + "StripePaymentForm.handleCardPaymentError": "We are unable to authenticate your payment method. Please check your authentication details and try again.", "StripePaymentForm.messageHeading": "Message", "StripePaymentForm.messageLabel": "Say hello to your host {messageOptionalText}", "StripePaymentForm.messageOptionalText": "• optional", @@ -806,6 +828,7 @@ "StripePaymentForm.stripe.validation_error.invalid_swipe_data": "The card's swipe data is invalid.", "StripePaymentForm.stripe.validation_error.missing": "There is no card on a customer that is being charged.", "StripePaymentForm.stripe.validation_error.processing_error": "An error occurred while processing the card.", + "StripePaymentForm.submitConfirmPaymentInfo": "Confirm request", "StripePaymentForm.submitPaymentInfo": "Send request", "TermsOfServicePage.heading": "Terms of Service", "TermsOfServicePage.privacyTabTitle": "Privacy Policy",