Create PaymentMethodsPage

This commit is contained in:
Jenni Nurmi 2019-07-25 10:44:18 +03:00
parent d4c265c014
commit 905f99043b
7 changed files with 466 additions and 3 deletions

View file

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

View file

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

View file

@ -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: <FormattedMessage id="PaymentMethodsPage.contactDetailsTabTitle" />,
selected: false,
linkProps: {
name: 'ContactDetailsPage',
},
},
{
text: <FormattedMessage id="PaymentMethodsPage.passwordTabTitle" />,
selected: false,
linkProps: {
name: 'PasswordChangePage',
},
},
{
text: <FormattedMessage id="PaymentMethodsPage.paymentsTabTitle" />,
selected: false,
linkProps: {
name: 'PayoutPreferencesPage',
},
},
{
text: <FormattedMessage id="PaymentMethodsPage.paymentMethodsTabTitle" />,
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 (
<Page title={title} scrollingDisabled={scrollingDisabled}>
<LayoutSideNavigation>
<LayoutWrapperTopbar>
<TopbarContainer
currentPage="PaymentMethodsPage"
desktopClassName={css.desktopTopbar}
mobileClassName={css.mobileTopbar}
/>
<UserNav selectedPageName="PaymentMethodsPage" />
</LayoutWrapperTopbar>
<LayoutWrapperSideNav tabs={tabs} />
<LayoutWrapperMain>
<div className={css.content}>
<h1 className={css.title}>
<FormattedMessage id="PaymentMethodsPage.heading" />
</h1>
{!stripeCustomerFetched ? null : (
<>
{showCardDetails ? (
<SavedCardDetails
card={card}
onManageDisableScrolling={onManageDisableScrolling}
onChange={setCardState}
onDeleteCard={handleRemovePaymentMethod}
deletePaymentMethodInProgress={deletePaymentMethodInProgress}
/>
) : null}
{showForm ? (
<PaymentMethodsForm
className={css.paymentForm}
formId="PaymentMethodsForm"
initialValues={initalValuesForStripePayment}
onSubmit={handleSubmit}
handleRemovePaymentMethod={handleRemovePaymentMethod}
hasDefaultPaymentMethod={hasDefaultPaymentMethod}
addPaymentMethodError={addPaymentMethodError}
deletePaymentMethodError={deletePaymentMethodError}
createStripeCustomerError={createStripeCustomerError}
handleCardSetupError={handleCardSetupError}
inProgress={isSubmitting}
/>
) : null}
</>
)}
</div>
</LayoutWrapperMain>
<LayoutWrapperFooter>
<Footer />
</LayoutWrapperFooter>
</LayoutSideNavigation>
</Page>
);
};
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;

View file

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

View file

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

View file

@ -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 => <PayoutPreferencesPage {...props} />,
loadData: PayoutPreferencesPage.loadData,
},
{
path: '/account/payment-methods',
name: 'PaymentMethodsPage',
auth: true,
authPage: 'LoginPage',
component: props => <PaymentMethodsPage {...props} />,
loadData: PaymentMethodsPage.loadData,
},
{
path: '/terms-of-service',
name: 'TermsOfServicePage',

View file

@ -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",