diff --git a/src/containers/PaymentMethodsPage/PaymentMethodsPage.css b/src/containers/PaymentMethodsPage/PaymentMethodsPage.css new file mode 100644 index 00000000..360e74b2 --- /dev/null +++ b/src/containers/PaymentMethodsPage/PaymentMethodsPage.css @@ -0,0 +1,28 @@ +@import '../../marketplace.css'; + +.content { + @media (--viewportMedium) { + margin: 32px auto 0 auto; + width: 100%; + max-width: 564px; + } + + @media (--viewportLarge) { + margin: 0; + } +} + +.desktopTopbar, +.mobileTopbar { + box-shadow: none; +} + +.title { + hyphens: auto; + margin-top: 8px; + margin-bottom: 19px; + + @media (--viewportMedium) { + margin-bottom: 47px; + } +} diff --git a/src/containers/PaymentMethodsPage/PaymentMethodsPage.duck.js b/src/containers/PaymentMethodsPage/PaymentMethodsPage.duck.js new file mode 100644 index 00000000..3634eade --- /dev/null +++ b/src/containers/PaymentMethodsPage/PaymentMethodsPage.duck.js @@ -0,0 +1,106 @@ +import { fetchCurrentUser } from '../../ducks/user.duck'; +import { setInitialValues as setInitialValuesForPaymentMethods } from '../../ducks/paymentMethods.duck'; +import { storableError } from '../../util/errors'; +import * as log from '../../util/log'; + +// ================ Action types ================ // + +export const SETUP_INTENT_REQUEST = 'app/PaymentMethodsPage/SETUP_INTENT_REQUEST'; +export const SETUP_INTENT_SUCCESS = 'app/PaymentMethodsPage/SETUP_INTENT_SUCCESS'; +export const SETUP_INTENT_ERROR = 'app/PaymentMethodsPage/SETUP_INTENT_ERROR'; + +export const STRIPE_CUSTOMER_REQUEST = 'app/PaymentMethodsPage/STRIPE_CUSTOMER_REQUEST'; +export const STRIPE_CUSTOMER_SUCCESS = 'app/PaymentMethodsPage/STRIPE_CUSTOMER_SUCCESS'; +export const STRIPE_CUSTOMER_ERROR = 'app/PaymentMethodsPage/STRIPE_CUSTOMER_ERROR'; + +// ================ Reducer ================ // + +const initialState = { + setupIntentInProgress: false, + setupIntentError: null, + setupIntent: null, + stripeCustomerFetched: false, +}; + +export default function payoutMethodsPageReducer(state = initialState, action = {}) { + const { type, payload } = action; + switch (type) { + case SETUP_INTENT_REQUEST: + return { ...state, setupIntentInProgress: true, setupIntentError: null }; + case SETUP_INTENT_SUCCESS: + return { + ...state, + setupIntentInProgress: false, + setupIntentError: null, + setupIntent: payload, + }; + case SETUP_INTENT_ERROR: + console.error(payload); // eslint-disable-line no-console + return { ...state, setupIntentInProgress: false, setupIntentError: null }; + case STRIPE_CUSTOMER_REQUEST: + return { ...state, stripeCustomerFetched: false }; + case STRIPE_CUSTOMER_SUCCESS: + return { ...state, stripeCustomerFetched: true }; + case STRIPE_CUSTOMER_ERROR: + console.error(payload); // eslint-disable-line no-console + return { ...state, stripeCustomerFetchError: payload }; + default: + return state; + } +} + +// ================ Action creators ================ // + +export const setupIntentRequest = () => ({ type: SETUP_INTENT_REQUEST }); +export const setupIntentSuccess = () => ({ type: SETUP_INTENT_SUCCESS }); +export const setupIntentError = e => ({ + type: SETUP_INTENT_ERROR, + error: true, + payload: e, +}); + +export const stripeCustomerRequest = () => ({ type: STRIPE_CUSTOMER_REQUEST }); +export const stripeCustomerSuccess = () => ({ type: STRIPE_CUSTOMER_SUCCESS }); +export const stripeCustomerError = e => ({ + type: STRIPE_CUSTOMER_ERROR, + error: true, + payload: e, +}); +// ================ Thunks ================ // + +export const createStripeSetupIntent = () => (dispatch, getState, sdk) => { + dispatch(setupIntentRequest()); + return sdk.stripeSetupIntents + .create() + .then(response => { + const setupIntent = response.data.data; + dispatch(setupIntentSuccess(setupIntent)); + return setupIntent; + }) + .catch(e => { + const error = storableError(e); + log.error(error, 'create-setup-intent-failed'); + dispatch(setupIntentError(error)); + return { createStripeSetupIntentSuccess: false }; + }); +}; + +export const stripeCustomer = () => (dispatch, getState, sdk) => { + dispatch(stripeCustomerRequest()); + + return dispatch(fetchCurrentUser({ include: ['stripeCustomer.defaultPaymentMethod'] })) + .then(response => { + dispatch(stripeCustomerSuccess()); + }) + .catch(e => { + const error = storableError(e); + log.error(error, 'fetch-stripe-customer-failed'); + dispatch(stripeCustomerError(error)); + }); +}; + +export const loadData = () => (dispatch, getState, sdk) => { + dispatch(setInitialValuesForPaymentMethods()); + + return dispatch(stripeCustomer()); +}; diff --git a/src/containers/PaymentMethodsPage/PaymentMethodsPage.js b/src/containers/PaymentMethodsPage/PaymentMethodsPage.js new file mode 100644 index 00000000..04447c74 --- /dev/null +++ b/src/containers/PaymentMethodsPage/PaymentMethodsPage.js @@ -0,0 +1,306 @@ +import React, { useState } from 'react'; +import { bool, func, object } from 'prop-types'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; +import { ensureCurrentUser, ensureStripeCustomer, ensurePaymentMethodCard } from '../../util/data'; +import { propTypes } from '../../util/types'; +import { savePaymentMethod, deletePaymentMethod } from '../../ducks/paymentMethods.duck'; +import { handleCardSetup } from '../../ducks/stripe.duck'; +import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck'; +import { + SavedCardDetails, + LayoutSideNavigation, + LayoutWrapperMain, + LayoutWrapperSideNav, + LayoutWrapperTopbar, + LayoutWrapperFooter, + Footer, + Page, + UserNav, +} from '../../components'; +import { TopbarContainer } from '../../containers'; +import { PaymentMethodsForm } from '../../forms'; +import { createStripeSetupIntent, stripeCustomer, loadData } from './PaymentMethodsPage.duck.js'; + +import css from './PaymentMethodsPage.css'; + +const PaymentMethodsPageComponent = props => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [cardState, setCardState] = useState(null); + + const { + currentUser, + addPaymentMethodError, + deletePaymentMethodError, + createStripeCustomerError, + handleCardSetupError, + deletePaymentMethodInProgress, + onCreateSetupIntent, + onHandleCardSetup, + onSavePaymentMethod, + onDeletePaymentMethod, + fetchStripeCustomer, + scrollingDisabled, + onManageDisableScrolling, + intl, + stripeCustomerFetched, + } = props; + + const getClientSecret = setupIntent => { + return setupIntent && setupIntent.attributes ? setupIntent.attributes.clientSecret : null; + }; + const getPaymentParams = (currentUser, formValues) => { + const { name, addressLine1, addressLine2, postal, state, city, country } = formValues; + const addressMaybe = + addressLine1 && postal + ? { + address: { + city: city, + country: country, + line1: addressLine1, + line2: addressLine2, + postal_code: postal, + state: state, + }, + } + : {}; + const billingDetails = { + name, + email: ensureCurrentUser(currentUser).attributes.email, + ...addressMaybe, + }; + + const paymentParams = { + payment_method_data: { + billing_details: billingDetails, + }, + }; + + return paymentParams; + }; + + const handleSubmit = params => { + setIsSubmitting(true); + const ensuredCurrentUser = ensureCurrentUser(currentUser); + const stripeCustomer = ensuredCurrentUser.stripeCustomer; + const { stripe, card, formValues } = params; + + onCreateSetupIntent() + .then(setupIntent => { + const stripeParams = { + stripe, + card, + setupIntentClientSecret: getClientSecret(setupIntent), + paymentParams: getPaymentParams(currentUser, formValues), + }; + + return onHandleCardSetup(stripeParams); + }) + .then(result => { + const newPaymentMethod = result.setupIntent.payment_method; + // Note: stripe.handleCardSetup might return an error inside successful call (200), but those are rejected in thunk functions. + + return onSavePaymentMethod(stripeCustomer, newPaymentMethod); + }) + .then(() => { + // Update currentUser entity and its sub entities: stripeCustomer and defaultPaymentMethod + fetchStripeCustomer(); + setIsSubmitting(false); + setCardState('default'); + }) + .catch(error => { + console.error(error); + setIsSubmitting(false); + }); + }; + + const handleRemovePaymentMethod = () => { + onDeletePaymentMethod().then(() => { + fetchStripeCustomer(); + }); + }; + + const tabs = [ + { + text: , + selected: false, + linkProps: { + name: 'ContactDetailsPage', + }, + }, + { + text: , + selected: false, + linkProps: { + name: 'PasswordChangePage', + }, + }, + { + text: , + selected: false, + linkProps: { + name: 'PayoutPreferencesPage', + }, + }, + { + text: , + selected: true, + linkProps: { + name: 'PaymentMethodsPage', + }, + }, + ]; + + const title = intl.formatMessage({ id: 'PaymentMethodsPage.title' }); + + const ensuredCurrentUser = ensureCurrentUser(currentUser); + const currentUserLoaded = !!ensuredCurrentUser.id; + + const hasDefaultPaymentMethod = + currentUser && + ensureStripeCustomer(currentUser.stripeCustomer).attributes.stripeCustomerId && + ensurePaymentMethodCard(currentUser.stripeCustomer.defaultPaymentMethod).id; + + // Get first and last name of the current user and use it in the StripePaymentForm to autofill the name field + const userName = currentUserLoaded + ? `${ensuredCurrentUser.attributes.profile.firstName} ${ + ensuredCurrentUser.attributes.profile.lastName + }` + : null; + + const initalValuesForStripePayment = { name: userName }; + + const card = hasDefaultPaymentMethod + ? ensurePaymentMethodCard(currentUser.stripeCustomer.defaultPaymentMethod).attributes.card + : null; + + const showForm = cardState === 'replaceCard' || !hasDefaultPaymentMethod; + const showCardDetails = !!hasDefaultPaymentMethod; + return ( + + + + + + + + + + + + + {!stripeCustomerFetched ? null : ( + <> + {showCardDetails ? ( + + ) : null} + {showForm ? ( + + ) : null} + > + )} + + + + + + + + ); +}; + +PaymentMethodsPageComponent.defaultProps = { + currentUser: null, + addPaymentMethodError: null, + deletePaymentMethodError: null, + createStripeCustomerError: null, + handleCardSetupError: null, +}; + +PaymentMethodsPageComponent.propTypes = { + currentUser: propTypes.currentUser, + scrollingDisabled: bool.isRequired, + addPaymentMethodError: object, + deletePaymentMethodError: object, + createStripeCustomerError: object, + handleCardSetupError: object, + onCreateSetupIntent: func.isRequired, + onHandleCardSetup: func.isRequired, + onSavePaymentMethod: func.isRequired, + onDeletePaymentMethod: func.isRequired, + fetchStripeCustomer: func.isRequired, + + // from injectIntl + intl: intlShape.isRequired, +}; + +const mapStateToProps = state => { + const { currentUser } = state.user; + + const { + deletePaymentMethodInProgress, + addPaymentMethodError, + deletePaymentMethodError, + createStripeCustomerError, + } = state.paymentMethods; + + const { stripeCustomerFetched } = state.PaymentMethodsPage; + + const { handleCardSetupError } = state.stripe; + return { + currentUser, + scrollingDisabled: isScrollingDisabled(state), + deletePaymentMethodInProgress, + addPaymentMethodError, + deletePaymentMethodError, + createStripeCustomerError, + handleCardSetupError, + stripeCustomerFetched, + }; +}; + +const mapDispatchToProps = dispatch => ({ + onManageDisableScrolling: (componentId, disableScrolling) => + dispatch(manageDisableScrolling(componentId, disableScrolling)), + fetchStripeCustomer: () => dispatch(stripeCustomer()), + onHandleCardSetup: params => dispatch(handleCardSetup(params)), + onCreateSetupIntent: params => dispatch(createStripeSetupIntent(params)), + onSavePaymentMethod: (stripeCustomer, newPaymentMethod) => + dispatch(savePaymentMethod(stripeCustomer, newPaymentMethod)), + onDeletePaymentMethod: params => dispatch(deletePaymentMethod(params)), +}); + +const PaymentMethodsPage = compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + injectIntl +)(PaymentMethodsPageComponent); + +PaymentMethodsPage.loadData = loadData; + +export default PaymentMethodsPage; diff --git a/src/containers/index.js b/src/containers/index.js index a8e1ff55..e9f041bd 100644 --- a/src/containers/index.js +++ b/src/containers/index.js @@ -12,6 +12,7 @@ export { default as NotFoundPage } from './NotFoundPage/NotFoundPage'; export { default as PasswordChangePage } from './PasswordChangePage/PasswordChangePage'; export { default as PasswordRecoveryPage } from './PasswordRecoveryPage/PasswordRecoveryPage'; export { default as PasswordResetPage } from './PasswordResetPage/PasswordResetPage'; +export { default as PaymentMethodsPage } from './PaymentMethodsPage/PaymentMethodsPage'; export { default as PayoutPreferencesPage } from './PayoutPreferencesPage/PayoutPreferencesPage'; export { default as PrivacyPolicyPage } from './PrivacyPolicyPage/PrivacyPolicyPage'; export { default as ProfilePage } from './ProfilePage/ProfilePage'; diff --git a/src/containers/reducers.js b/src/containers/reducers.js index 36f402f8..aaff1505 100644 --- a/src/containers/reducers.js +++ b/src/containers/reducers.js @@ -13,6 +13,7 @@ import PasswordChangePage from './PasswordChangePage/PasswordChangePage.duck'; import PasswordRecoveryPage from './PasswordRecoveryPage/PasswordRecoveryPage.duck'; import PasswordResetPage from './PasswordResetPage/PasswordResetPage.duck'; import PayoutPreferencesPage from './PayoutPreferencesPage/PayoutPreferencesPage.duck'; +import PaymentMethodsPage from './PaymentMethodsPage/PaymentMethodsPage.duck'; import ProfilePage from './ProfilePage/ProfilePage.duck'; import ProfileSettingsPage from './ProfileSettingsPage/ProfileSettingsPage.duck'; import SearchPage from './SearchPage/SearchPage.duck'; @@ -29,6 +30,7 @@ export { PasswordRecoveryPage, PasswordResetPage, PayoutPreferencesPage, + PaymentMethodsPage, ProfilePage, ProfileSettingsPage, SearchPage, diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index 7176f08d..244d4cfe 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -15,6 +15,7 @@ import { PasswordRecoveryPage, PasswordResetPage, PayoutPreferencesPage, + PaymentMethodsPage, PrivacyPolicyPage, ProfilePage, ProfileSettingsPage, @@ -33,6 +34,7 @@ export const ACCOUNT_SETTINGS_PAGES = [ 'ContactDetailsPage', 'PasswordChangePage', 'PayoutPreferencesPage', + 'PaymentMethodsPage', ]; // https://en.wikipedia.org/wiki/Universally_unique_identifier#Nil_UUID @@ -249,6 +251,14 @@ const routeConfiguration = () => { component: props => , loadData: PayoutPreferencesPage.loadData, }, + { + path: '/account/payment-methods', + name: 'PaymentMethodsPage', + auth: true, + authPage: 'LoginPage', + component: props => , + loadData: PaymentMethodsPage.loadData, + }, { path: '/terms-of-service', name: 'TermsOfServicePage', diff --git a/src/translations/en.json b/src/translations/en.json index a4b95353..cacfa8e6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -475,6 +475,16 @@ "PaymentMethodsForm.infoText": "I authorise Saunatime to send instructions to the financial institution that issued my card to take payments from my card account in accordance with the terms of my agreement with you.", "PaymentMethodsForm.paymentHeading": "Payment", "PaymentMethodsForm.submitPaymentInfo": "Save credit card", + "PaymentMethodsPage.contactDetailsTabTitle": "Contact details", + "PaymentMethodsPage.deletePaymentMethod": "Delete card", + "PaymentMethodsPage.heading": "Payment methods", + "PaymentMethodsPage.loadingData": "Loading data…", + "PaymentMethodsPage.passwordTabTitle": "Password", + "PaymentMethodsPage.paymentsTabTitle": "Payout details", + "PaymentMethodsPage.paymentMethodsTabTitle": "Payment methods", + "PaymentMethodsPage.savedPaymentMethodTitle": "Credit card details", + "PaymentMethodsPage.savedPaymentMethodPlaceholder": "•••• •••• •••• {lastFour}", + "PaymentMethodsPage.title": "Payment methods", "PayoutDetailsForm.accountOpenerInfoText": "Account opener needs to be an individual with authorization to sign on behalf of the organization.", "PayoutDetailsForm.accountOpenerTitle": "Account opener", "PayoutDetailsForm.accountTypeTitle": "Account type", @@ -694,11 +704,11 @@ "ReviewModal.later": "Later", "ReviewModal.title": "Leave a review for {revieweeName}", "SavedCardDetails.cancel": "Cancel", - "SavedCardDetails.deletePaymentMethod": "Delete payment card", + "SavedCardDetails.deletePaymentMethod": "Remove payment card", "SavedCardDetails.expiredCardText": "This payment card has expired", "SavedCardDetails.removeCard": "Remove card", "SavedCardDetails.removeCardModalContent": "The card will be removed from your account and can't be used for future purchases.", - "SavedCardDetails.removeCardModalTitle": "Are you sure you want to remove this credit card?", + "SavedCardDetails.removeCardModalTitle": "Are you sure you want to remove this payment card?", "SavedCardDetails.replaceCardText": "Credit or debit card", "SavedCardDetails.replaceCardTitle": "Use another payment method", "SavedCardDetails.savedPaymentMethodPlaceholderDesktop": "•••• •••• •••• {lastFour}", @@ -816,7 +826,7 @@ "StripePaymentAddress.addressLine1Label": "Street address", "StripePaymentAddress.addressLine1Placeholder": "123 Example Street", "StripePaymentAddress.addressLine1Required": "Street address is required", - "StripePaymentAddress.addressLine2Label": "Apt, suite, building # {optionalText}", + "StripePaymentAddress.addressLine2Label": "Apt # {optionalText}", "StripePaymentAddress.addressLine2Placeholder": "A 42", "StripePaymentAddress.cityLabel": "City", "StripePaymentAddress.cityPlaceholder": "Helsinki",