From 6ef5da91dfecfe34b0a885ea2b7bd00f8c73ce46 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Wed, 21 Aug 2019 16:22:28 +0300 Subject: [PATCH 01/21] Update SDK version --- package.json | 2 +- yarn.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 51997641..8002f0c2 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "redux-thunk": "^2.3.0", "sanitize.css": "^5.0.0", "seedrandom": "^2.4.4", - "sharetribe-flex-sdk": "^1.4.1", + "sharetribe-flex-sdk": "^1.5.0", "sharetribe-scripts": "3.0.0", "smoothscroll-polyfill": "^0.4.0", "source-map-support": "^0.5.9", diff --git a/yarn.lock b/yarn.lock index 84630716..7a4cffa2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6375,9 +6375,9 @@ jest@24.7.1: jest-cli "^24.7.1" js-cookie@^2.1.3: - version "2.2.0" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.0.tgz#1b2c279a6eece380a12168b92485265b35b1effb" - integrity sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s= + version "2.2.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" + integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== js-levenshtein@^1.1.3: version "1.1.6" @@ -9999,10 +9999,10 @@ shallowequal@^1.0.1: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== -sharetribe-flex-sdk@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.4.1.tgz#abf1af12d34a31e05abb0121cd55f2f0716c46c1" - integrity sha512-QcsEoOf2SdTW2NwkNbpaAKhT5P8s640dGZg9NPuzz5NaSESgl+gkkeHNIDRHnycyvINvl6IjGPmUFTPHYA1XkA== +sharetribe-flex-sdk@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.5.0.tgz#a1bb9c603f8a52a1f53053cb610fdf15ff223288" + integrity sha512-l/3vX9GEz9Uz9z+9+WDHH6/O1GMSh8sa1kQpEHBjypx3kDjFT2a6SSxEzk3YQ/qIaUEHvGj9G+Xy3AszF6pCrA== dependencies: axios "^0.19.0" js-cookie "^2.1.3" From 3ee937757b3cdd8a45855d2205dd8068acbe0a72 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Sat, 20 Jul 2019 16:47:10 +0300 Subject: [PATCH 02/21] Shared duck file: paymentMethods.duck.js --- src/ducks/index.js | 2 + src/ducks/paymentMethods.duck.js | 226 +++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/ducks/paymentMethods.duck.js diff --git a/src/ducks/index.js b/src/ducks/index.js index be63d904..fa18d646 100644 --- a/src/ducks/index.js +++ b/src/ducks/index.js @@ -11,6 +11,7 @@ import LocationFilter from './LocationFilter.duck'; import Routing from './Routing.duck'; import UI from './UI.duck'; import marketplaceData from './marketplaceData.duck'; +import paymentMethods from './paymentMethods.duck'; import stripe from './stripe.duck'; import user from './user.duck'; @@ -22,6 +23,7 @@ export { Routing, UI, marketplaceData, + paymentMethods, stripe, user, }; diff --git a/src/ducks/paymentMethods.duck.js b/src/ducks/paymentMethods.duck.js new file mode 100644 index 00000000..633e7665 --- /dev/null +++ b/src/ducks/paymentMethods.duck.js @@ -0,0 +1,226 @@ +import pick from 'lodash/pick'; +import { storableError } from '../util/errors'; +import * as log from '../util/log'; + +// ================ Action types ================ // + +export const SET_INITIAL_VALUES = 'app/paymentMethods/SET_INITIAL_VALUES'; + +export const CREATE_STRIPE_CUSTOMER_REQUEST = 'app/paymentMethods/CREATE_STRIPE_CUSTOMER_REQUEST'; +export const CREATE_STRIPE_CUSTOMER_SUCCESS = 'app/paymentMethods/CREATE_STRIPE_CUSTOMER_SUCCESS'; +export const CREATE_STRIPE_CUSTOMER_ERROR = 'app/paymentMethods/CREATE_STRIPE_CUSTOMER_ERROR'; + +export const ADD_PAYMENT_METHOD_REQUEST = 'app/paymentMethods/ADD_PAYMENT_METHOD_REQUEST'; +export const ADD_PAYMENT_METHOD_SUCCESS = 'app/paymentMethods/ADD_PAYMENT_METHOD_SUCCESS'; +export const ADD_PAYMENT_METHOD_ERROR = 'app/paymentMethods/ADD_PAYMENT_METHOD_ERROR'; + +export const DELETE_PAYMENT_METHOD_REQUEST = 'app/paymentMethods/DELETE_PAYMENT_METHOD_REQUEST'; +export const DELETE_PAYMENT_METHOD_SUCCESS = 'app/paymentMethods/DELETE_PAYMENT_METHOD_SUCCESS'; +export const DELETE_PAYMENT_METHOD_ERROR = 'app/paymentMethods/DELETE_PAYMENT_METHOD_ERROR'; + +// ================ Reducer ================ // + +const initialState = { + addPaymentMethodInProgress: null, + addPaymentMethodError: null, + deletePaymentMethodInProgress: null, + deletePaymentMethodError: null, + createStripeCustomerInProgress: null, + createStripeCustomerError: null, + stripeCustomer: null, +}; + +export default function payoutMethodsPageReducer(state = initialState, action = {}) { + const { type, payload } = action; + switch (type) { + case SET_INITIAL_VALUES: + return { ...initialState, ...payload }; + case CREATE_STRIPE_CUSTOMER_REQUEST: + return { ...state, createStripeCustomerError: null, createStripeCustomerInProgress: true }; + case CREATE_STRIPE_CUSTOMER_SUCCESS: + return { + ...state, + createStripeCustomerInProgress: false, + stripeCustomer: payload, + }; + case CREATE_STRIPE_CUSTOMER_ERROR: + console.error(payload); + return { + ...state, + createStripeCustomerError: payload, + createStripeCustomerInProgress: false, + }; + case ADD_PAYMENT_METHOD_REQUEST: + return { ...state, addPaymentMethodError: null, addPaymentMethodInProgress: true }; + case ADD_PAYMENT_METHOD_SUCCESS: + return { + ...state, + addPaymentMethodInProgress: false, + stripeCustomer: payload, + }; + case ADD_PAYMENT_METHOD_ERROR: + console.error(payload); + return { + ...state, + addPaymentMethodError: payload, + addPaymentMethodInProgress: false, + }; + + case DELETE_PAYMENT_METHOD_REQUEST: + return { ...state, deletePaymentMethodError: null, deletePaymentMethodInProgress: true }; + case DELETE_PAYMENT_METHOD_SUCCESS: + return { + ...state, + deletePaymentMethodInProgress: false, + }; + case DELETE_PAYMENT_METHOD_ERROR: + console.error(payload); + return { + ...state, + deletePaymentMethodError: payload, + deletePaymentMethodInProgress: false, + }; + default: + return state; + } +} + +// ================ Action creators ================ // + +export const setInitialValues = initialValues => ({ + type: SET_INITIAL_VALUES, + payload: pick(initialValues, Object.keys(initialState)), +}); + +export const stripeCustomerCreateRequest = () => ({ type: CREATE_STRIPE_CUSTOMER_REQUEST }); + +export const stripeCustomerCreateSuccess = stripeCustomer => ({ + type: CREATE_STRIPE_CUSTOMER_SUCCESS, + payload: stripeCustomer, +}); + +export const stripeCustomerCreateError = e => ({ + type: CREATE_STRIPE_CUSTOMER_ERROR, + payload: e, + error: true, +}); + +export const addPaymentMethodRequest = () => ({ type: ADD_PAYMENT_METHOD_REQUEST }); + +export const addPaymentMethodSuccess = stripeCustomer => ({ + type: ADD_PAYMENT_METHOD_SUCCESS, + payload: stripeCustomer, +}); + +export const addPaymentMethodError = e => ({ + type: ADD_PAYMENT_METHOD_ERROR, + payload: e, + error: true, +}); + +export const deletePaymentMethodRequest = () => ({ type: DELETE_PAYMENT_METHOD_REQUEST }); + +export const deletePaymentMethodSuccess = stripeCustomer => ({ + type: DELETE_PAYMENT_METHOD_SUCCESS, + payload: stripeCustomer, +}); + +export const deletePaymentMethodError = e => ({ + type: DELETE_PAYMENT_METHOD_ERROR, + payload: e, + error: true, +}); + +// ================ Thunks ================ // + +export const createStripeCustomer = stripePaymentMethodId => (dispatch, getState, sdk) => { + dispatch(stripeCustomerCreateRequest()); + return sdk.stripeCustomer + .create({ stripePaymentMethodId }, { expand: true, include: ['defaultPaymentMethod'] }) + .then(response => { + const stripeCustomer = response.data.data; + dispatch(stripeCustomerCreateSuccess(stripeCustomer)); + return stripeCustomer; + }) + .catch(e => { + log.error(storableError(e), 'create-stripe-user-failed'); + dispatch(stripeCustomerCreateError(storableError(e))); + }); +}; + +export const addPaymentMethod = stripePaymentMethodId => (dispatch, getState, sdk) => { + dispatch(addPaymentMethodRequest()); + return sdk.stripeCustomer + .addPaymentMethod({ stripePaymentMethodId }, { expand: true }) + .then(response => { + const stripeCustomer = response.data.data; + dispatch(addPaymentMethodSuccess(stripeCustomer)); + return stripeCustomer; + }) + .catch(e => { + log.error(storableError(e), 'add-payment-method-failed'); + dispatch(addPaymentMethodError(storableError(e))); + }); +}; + +export const deletePaymentMethod = () => (dispatch, getState, sdk) => { + dispatch(deletePaymentMethodRequest()); + return sdk.stripeCustomer + .deletePaymentMethod({}, { expand: true }) + .then(response => { + const stripeCustomer = response.data.data; + dispatch(deletePaymentMethodSuccess(stripeCustomer)); + return stripeCustomer; + }) + .catch(e => { + log.error(storableError(e), 'add-payment-method-failed'); + dispatch(deletePaymentMethodError(storableError(e))); + }); +}; + +export const updatePaymentMethod = stripePaymentMethodId => (dispatch, getState, sdk) => { + return dispatch(deletePaymentMethod()) + .then(() => { + return dispatch(addPaymentMethod(stripePaymentMethodId)); + }) + .catch(e => { + log.error(storableError(e), 'updating-payment-method-failed'); + }); +}; + +// This function helps to choose correct thunk function +export const savePaymentMethod = (stripeCustomer, stripePaymentMethodId) => ( + dispatch, + getState, + sdk +) => { + const hasAlreadyDefaultPaymentMethod = + stripeCustomer && stripeCustomer.defaultPaymentMethod && stripeCustomer.defaultPaymentMethod.id; + + const savePromise = + !stripeCustomer || !stripeCustomer.id + ? dispatch(createStripeCustomer(stripePaymentMethodId)) + : hasAlreadyDefaultPaymentMethod + ? dispatch(updatePaymentMethod(stripePaymentMethodId)) + : dispatch(addPaymentMethod(stripePaymentMethodId)); + + return savePromise + .then(response => { + const { + createStripeCustomerError, + addPaymentMethodError, + deletePaymentMethodError, + } = getState().paymentMethods; + + // If there are any errors, return those errors + if (createStripeCustomerError || addPaymentMethodError || deletePaymentMethodError) { + return { + errors: { createStripeCustomerError, addPaymentMethodError, deletePaymentMethodError }, + }; + } + return response; + }) + .catch(e => { + // errors are already catched in other thunk functions. + }); +}; From 839afb6f4b8c8e6a3905a56e305b1b22e0ffe06b Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 23 Jul 2019 13:19:12 +0200 Subject: [PATCH 03/21] user.duck.js can fetch currentUser with different relationships. It merges old and new relationships. --- src/ducks/user.duck.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/ducks/user.duck.js b/src/ducks/user.duck.js index a2b96945..78f716bc 100644 --- a/src/ducks/user.duck.js +++ b/src/ducks/user.duck.js @@ -40,6 +40,20 @@ export const SEND_VERIFICATION_EMAIL_ERROR = 'app/user/SEND_VERIFICATION_EMAIL_E // ================ Reducer ================ // +const mergeCurrentUser = (oldCurrentUser, newCurrentUser) => { + const { id: oId, type: oType, attributes: oAttr, ...oldRelationships } = oldCurrentUser || {}; + const { id, type, attributes, ...relationships } = newCurrentUser || {}; + + // Passing null will remove currentUser entity. + // Only relationships are merged. + // TODO figure out if sparse fields handling needs a better handling. + return newCurrentUser === null + ? null + : oldCurrentUser === null + ? newCurrentUser + : { id, type, attributes, ...oldRelationships, ...relationships }; +}; + const initialState = { currentUser: null, currentUserShowError: null, @@ -59,7 +73,7 @@ export default function reducer(state = initialState, action = {}) { case CURRENT_USER_SHOW_REQUEST: return { ...state, currentUserShowError: null }; case CURRENT_USER_SHOW_SUCCESS: - return { ...state, currentUser: payload }; + return { ...state, currentUser: mergeCurrentUser(state.currentUser, payload) }; case CURRENT_USER_SHOW_ERROR: // eslint-disable-next-line no-console console.error(payload); @@ -291,7 +305,7 @@ export const fetchCurrentUserNotifications = () => (dispatch, getState, sdk) => .catch(e => dispatch(fetchCurrentUserNotificationsError(storableError(e)))); }; -export const fetchCurrentUser = () => (dispatch, getState, sdk) => { +export const fetchCurrentUser = (params = null) => (dispatch, getState, sdk) => { dispatch(currentUserShowRequest()); const { isAuthenticated } = getState().Auth; @@ -301,13 +315,13 @@ export const fetchCurrentUser = () => (dispatch, getState, sdk) => { return Promise.resolve({}); } - const params = { + const parameters = params || { include: ['profileImage', 'stripeAccount'], 'fields.image': ['variants.square-small', 'variants.square-small2x'], }; return sdk.currentUser - .show(params) + .show(parameters) .then(response => { const entities = denormalisedResponseEntities(response); if (entities.length !== 1) { From 6521ec7ab78591db748e9cf4b0177ebffa7952c1 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Thu, 25 Jul 2019 12:23:20 +0300 Subject: [PATCH 04/21] Add handleCardSetup to stripe.duck.js --- src/ducks/stripe.duck.js | 83 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/ducks/stripe.duck.js b/src/ducks/stripe.duck.js index f0d6ca87..cd533edb 100644 --- a/src/ducks/stripe.duck.js +++ b/src/ducks/stripe.duck.js @@ -24,6 +24,10 @@ export const HANDLE_CARD_PAYMENT_REQUEST = 'app/stripe/HANDLE_CARD_PAYMENT_REQUE export const HANDLE_CARD_PAYMENT_SUCCESS = 'app/stripe/HANDLE_CARD_PAYMENT_SUCCESS'; export const HANDLE_CARD_PAYMENT_ERROR = 'app/stripe/HANDLE_CARD_PAYMENT_ERROR'; +export const HANDLE_CARD_SETUP_REQUEST = 'app/stripe/HANDLE_CARD_SETUP_REQUEST'; +export const HANDLE_CARD_SETUP_SUCCESS = 'app/stripe/HANDLE_CARD_SETUP_SUCCESS'; +export const HANDLE_CARD_SETUP_ERROR = 'app/stripe/HANDLE_CARD_SETUP_ERROR'; + export const CLEAR_HANDLE_CARD_PAYMENT = 'app/stripe/CLEAR_HANDLE_CARD_PAYMENT'; export const RETRIEVE_PAYMENT_INTENT_REQUEST = 'app/stripe/RETRIEVE_PAYMENT_INTENT_REQUEST'; @@ -43,7 +47,10 @@ const initialState = { stripeAccountFetched: false, handleCardPaymentInProgress: false, handleCardPaymentError: null, + handleCardSetupInProgress: false, + handleCardSetupError: null, paymentIntent: null, + setupIntent: null, retrievePaymentIntentInProgress: false, retrievePaymentIntentError: null, }; @@ -123,6 +130,18 @@ export default function reducer(state = initialState, action = {}) { console.error(payload); return { ...state, handleCardPaymentError: payload, handleCardPaymentInProgress: false }; + case HANDLE_CARD_SETUP_REQUEST: + return { + ...state, + handleCardSetupError: null, + handleCardSetupInProgress: true, + }; + case HANDLE_CARD_SETUP_SUCCESS: + return { ...state, setupIntent: payload, handleCardSetupInProgress: false }; + case HANDLE_CARD_SETUP_ERROR: + console.error(payload); + return { ...state, handleCardSetupError: payload, handleCardSetupInProgress: false }; + case CLEAR_HANDLE_CARD_PAYMENT: return { ...state, @@ -218,6 +237,21 @@ export const handleCardPaymentError = payload => ({ error: true, }); +export const handleCardSetupRequest = () => ({ + type: HANDLE_CARD_SETUP_REQUEST, +}); + +export const handleCardSetupSuccess = payload => ({ + type: HANDLE_CARD_SETUP_SUCCESS, + payload, +}); + +export const handleCardSetupError = payload => ({ + type: HANDLE_CARD_SETUP_ERROR, + payload, + error: true, +}); + export const initializeCardPaymentData = () => ({ type: CLEAR_HANDLE_CARD_PAYMENT, }); @@ -617,8 +651,15 @@ export const handleCardPayment = params => dispatch => { dispatch(handleCardPaymentRequest()); + // When using default payment method, card (aka Stripe Element) is not needed. + // We also set paymentParams.payment_method already in Flex API side, + // when request-payment transition is made - so there's no need for paymentParams + const args = card + ? [stripePaymentIntentClientSecret, card, paymentParams] + : [stripePaymentIntentClientSecret]; + return stripe - .handleCardPayment(stripePaymentIntentClientSecret, card, paymentParams) + .handleCardPayment(...args) .then(response => { if (response.error) { return Promise.reject(response); @@ -649,3 +690,43 @@ export const handleCardPayment = params => dispatch => { throw e; }); }; + +export const handleCardSetup = params => dispatch => { + // It's required to use the same instance of Stripe as where the card has been created + // so that's why Stripe needs to be passed here and we can't create a new instance. + const { stripe, card, setupIntentClientSecret, paymentParams } = params; + + dispatch(handleCardSetupRequest()); + + return stripe + .handleCardSetup(setupIntentClientSecret, card, paymentParams) + .then(response => { + if (response.error) { + return Promise.reject(response); + } else { + dispatch(handleCardSetupSuccess(response)); + return response; + } + }) + .catch(err => { + // Unwrap Stripe error. + const e = err.error || storableError(err); + dispatch(handleCardSetupError(e)); + + // Log error + const containsSetupIntent = err.error && err.error.setup_intent; + const { code, doc_url, message, setup_intent } = containsSetupIntent ? err.error : {}; + const loggableError = containsSetupIntent + ? { + code, + message, + doc_url, + paymentIntentStatus: setup_intent.status, + } + : e; + log.error(loggableError, 'stripe-handle-card-setup-failed', { + stripeMessage: loggableError.message, + }); + throw e; + }); +}; From 74c0ce96e5f5364f919428bd7f8af9959558935f Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Sun, 28 Jul 2019 20:26:57 +0200 Subject: [PATCH 05/21] Add ensureStripeCustomer and ensurePaymentMethodCard --- src/util/data.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/util/data.js b/src/util/data.js index cd976cc6..b508e77f 100644 --- a/src/util/data.js +++ b/src/util/data.js @@ -244,6 +244,37 @@ export const ensureAvailabilityException = availabilityException => { return { ...empty, ...availabilityException }; }; +/** + * Create shell objects to ensure that attributes etc. exists. + * + * @param {Object} stripeCustomer entity from API, which is to be ensured against null values + */ +export const ensureStripeCustomer = stripeCustomer => { + const empty = { id: null, type: 'stripeCustomer', attributes: {} }; + return { ...empty, ...stripeCustomer }; +}; + +/** + * Create shell objects to ensure that attributes etc. exists. + * + * @param {Object} stripeCustomer entity from API, which is to be ensured against null values + */ +export const ensurePaymentMethodCard = stripePaymentMethod => { + const empty = { + id: null, + type: 'stripePaymentMethod', + attributes: { type: 'stripe-payment-method/card', card: {} }, + }; + const cardPaymentMethod = { ...empty, ...stripePaymentMethod }; + + if (cardPaymentMethod.attributes.type !== 'stripe-payment-method/card') { + throw new Error(`'ensurePaymentMethodCard' got payment method with wrong type. + 'stripe-payment-method/card' was expected, received ${cardPaymentMethod.attributes.type}`); + } + + return cardPaymentMethod; +}; + /** * Get the display name of the given user as string. This function handles * missing data (e.g. when the user object is still being downloaded), From 262bfba6b3ccfc9ba7c12b95b79ac4dffd3eddd9 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Thu, 8 Aug 2019 10:47:21 +0300 Subject: [PATCH 06/21] Create IconCard --- src/components/IconCard/IconCard.css | 4 + src/components/IconCard/IconCard.js | 264 +++++++++++++++++++++++++++ src/components/index.js | 1 + 3 files changed, 269 insertions(+) create mode 100644 src/components/IconCard/IconCard.css create mode 100644 src/components/IconCard/IconCard.js diff --git a/src/components/IconCard/IconCard.css b/src/components/IconCard/IconCard.css new file mode 100644 index 00000000..93adba15 --- /dev/null +++ b/src/components/IconCard/IconCard.css @@ -0,0 +1,4 @@ +@import '../../marketplace.css'; + +.root { +} diff --git a/src/components/IconCard/IconCard.js b/src/components/IconCard/IconCard.js new file mode 100644 index 00000000..1b41694c --- /dev/null +++ b/src/components/IconCard/IconCard.js @@ -0,0 +1,264 @@ +import React from 'react'; +import { string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './IconCard.css'; + +const BRAND_AMEX = 'amex'; +const BRAND_DINERS = 'diners'; +const BRAND_DISCOVERY = 'discovery'; +const BRAND_JCB = 'jcb'; +const BRAND_MASTERCARD = 'mastercard'; +const BRAND_UNIONPAY = 'unionpay'; +const BRAND_VISA = 'visa'; + +const IconCard = props => { + const { className, rootClassName, brand } = props; + const classes = classNames(rootClassName || css.root, className); + + switch (brand) { + case BRAND_AMEX: + return ( + + + + + + + + + + + + + + ); + case BRAND_DINERS: + return ( + + + + + + + + ); + case BRAND_DISCOVERY: + return ( + + + + + + + + + ); + case BRAND_JCB: + return ( + + + + + + + + + + + ); + case BRAND_MASTERCARD: + return ( + + + + + + + + + ); + case BRAND_UNIONPAY: + return ( + + + + + + + + + ); + case BRAND_VISA: + return ( + + + + + + + ); + default: + return ( + + + + + + + ); + } +}; + +IconCard.defaultProps = { + className: null, + rootClassName: null, + brand: 'default', +}; + +IconCard.propTypes = { + className: string, + rootClassName: string, + brand: string, +}; + +export default IconCard; diff --git a/src/components/index.js b/src/components/index.js index aee68ee0..5aa72405 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -14,6 +14,7 @@ export { default as IconAdd } from './IconAdd/IconAdd'; export { default as IconArrowHead } from './IconArrowHead/IconArrowHead'; export { default as IconBannedUser } from './IconBannedUser/IconBannedUser'; +export { default as IconCard } from './IconCard/IconCard'; export { default as IconCheckmark } from './IconCheckmark/IconCheckmark'; export { default as IconClose } from './IconClose/IconClose'; export { default as IconEmailAttention } from './IconEmailAttention/IconEmailAttention'; From c1bfed5e7d35930a2a67cfe64b6aba0e594b270f Mon Sep 17 00:00:00 2001 From: Janne Koivistoinen Date: Mon, 19 Aug 2019 18:41:18 +0300 Subject: [PATCH 07/21] Add more directions to IconArrowHead --- src/components/IconArrowHead/IconArrowHead.js | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/components/IconArrowHead/IconArrowHead.js b/src/components/IconArrowHead/IconArrowHead.js index 43c721f1..dc3c1237 100644 --- a/src/components/IconArrowHead/IconArrowHead.js +++ b/src/components/IconArrowHead/IconArrowHead.js @@ -6,6 +6,8 @@ import css from './IconArrowHead.css'; const DIRECTION_RIGHT = 'right'; const DIRECTION_LEFT = 'left'; +const DIRECTION_DOWN = 'down'; +const DIRECTION_UP = 'up'; const SIZE_BIG = 'big'; const SIZE_SMALL = 'small'; @@ -15,6 +17,8 @@ const IconArrowHead = props => { const isRight = direction === DIRECTION_RIGHT; const isLeft = direction === DIRECTION_LEFT; + const isDown = direction === DIRECTION_DOWN; + const isUp = direction === DIRECTION_UP; const isBig = size === SIZE_BIG; const isSmall = size === SIZE_SMALL; @@ -48,6 +52,36 @@ const IconArrowHead = props => { /> ); + } else if (isDown && isSmall) { + return ( + + + + ); + } else if (isUp && isSmall) { + return ( + + + + ); } else if (isRight && isBig) { return ( { /> ); + } else if (isDown && isBig) { + return ( + + + + ); + } else if (isUp && isBig) { + return ( + + + + ); } }; @@ -90,7 +154,7 @@ IconArrowHead.defaultProps = { IconArrowHead.propTypes = { className: string, rootClassName: string, - direction: oneOf([DIRECTION_RIGHT, DIRECTION_LEFT]).isRequired, + direction: oneOf([DIRECTION_RIGHT, DIRECTION_LEFT, DIRECTION_DOWN, DIRECTION_UP]).isRequired, size: oneOf([SIZE_BIG, SIZE_SMALL]), }; From 89e7846519c6bf0b07572c61fb3c1f5c27615358 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Wed, 21 Aug 2019 10:44:49 +0300 Subject: [PATCH 08/21] IconCheckmark: big and small versions --- src/components/IconCheckmark/IconCheckmark.js | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/components/IconCheckmark/IconCheckmark.js b/src/components/IconCheckmark/IconCheckmark.js index 377d6e78..555f70b1 100644 --- a/src/components/IconCheckmark/IconCheckmark.js +++ b/src/components/IconCheckmark/IconCheckmark.js @@ -4,19 +4,38 @@ import classNames from 'classnames'; import css from './IconCheckMark.css'; +const SIZE_SMALL = 'small'; +const SIZE_BIG = 'big'; + const IconCheckmark = props => { - const { rootClassName, className } = props; + const { rootClassName, className, size } = props; const classes = classNames(rootClassName || css.root, className); - return ( - - - - ); + if (size === SIZE_SMALL) { + return ( + + + + ); + } else if (size === SIZE_BIG) { + return ( + + + + ); + } }; IconCheckmark.defaultProps = { rootClassName: null, className: null, + size: 'big', }; const { string } = PropTypes; @@ -24,6 +43,7 @@ const { string } = PropTypes; IconCheckmark.propTypes = { rootClassName: string, className: string, + size: string, }; export default IconCheckmark; From 40a3ab09aa62b16ce940ce4e0e6e30360d9611fb Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Thu, 8 Aug 2019 10:47:38 +0300 Subject: [PATCH 09/21] Create SavedCardDetailsComponent --- .../SavedCardDetails/SavedCardDetails.css | 236 +++++++++++++++++ .../SavedCardDetails.example.js | 53 ++++ .../SavedCardDetails/SavedCardDetails.js | 240 ++++++++++++++++++ src/components/index.js | 1 + src/examples.js | 2 + src/translations/en.json | 10 + 6 files changed, 542 insertions(+) create mode 100644 src/components/SavedCardDetails/SavedCardDetails.css create mode 100644 src/components/SavedCardDetails/SavedCardDetails.example.js create mode 100644 src/components/SavedCardDetails/SavedCardDetails.js diff --git a/src/components/SavedCardDetails/SavedCardDetails.css b/src/components/SavedCardDetails/SavedCardDetails.css new file mode 100644 index 00000000..cc7e5899 --- /dev/null +++ b/src/components/SavedCardDetails/SavedCardDetails.css @@ -0,0 +1,236 @@ +@import '../../marketplace.css'; + +.root { +} + +.savedPaymentMethodTitle { + @apply --marketplaceH4FontStyles; + font-weight: bold; + cursor: pointer; +} + +.savedPaymentMethod { + display: inline-block; + margin-right: auto; +} + +.paymentMethodPlaceholderDesktop { + display: none; +} + +.paymentMethodPlaceholderMobile { + display: inline-block; +} + +@media (--viewportSmall) { + .paymentMethodPlaceholderDesktop { + display: inline-block; + } + + .paymentMethodPlaceholderMobile { + display: none; + } +} + +.cardIcon { + margin: 0 12px 0 4px; + width: 24px; + height: 16px; + display: inline-block; +} + +.closeIcon { + @apply --marketplaceModalCloseIcon; + margin-left: 0; + margin-right: 5px; +} + +.iconCheckmark { + stroke: var(--successColor); + display: inline-block; + margin: 12px 0 0 24px; +} + +.iconCheckmarkHidden { + visibility: hidden; + display: inline-block; + margin: 0 0 0 24px; +} + +.expirationDate { + padding-left: 24px; +} + +.expirationDateExpired { + padding-left: 24px; + color: var(--failColor); +} + +.cardExpiredText { + @apply --marketplaceH4FontStyles; + color: var(--failColor); + font-weight: var(--fontWeightSemiBold); + margin-top: 13px; + margin-bottom: 11px; +} + +.menu { + max-width: 100%; + height: auto; +} + +.menuLabel { + text-align: left; + font-weight: 500; + padding: 0; + border-radius: 4px; + border: 1px solid var(--matterColorNegative); + transition: all 0.2s ease-out; + z-index: calc(var(--zIndexPopup) + 1); + position: relative; + + &:hover { + background-color: var(--matterColorLight); + border: 1px solid var(--matterColorAnti); + } + + &:focus { + outline: none; + background-color: var(--matterColorLight); + border: 1px solid var(--matterColorNegative); + text-decoration: none; + border-radius: 4px 4px 0px 0px; + } +} + +.menuLabelWrapper, +.menuLabelWrapperExpired { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + padding: 12px 24px 18px 16px; +} + +.menuLabelWrapperExpired { + border: 1px solid var(--failColor); +} + +.menuContent { + position: absolute; + z-index: var(--zIndexPopup); + + background-color: var(--matterColorLight); + box-shadow: var(--boxShadowFilterButton); + border-radius: 4px; + border: 1px solid var(--matterColorNegative); + + transition: all 0.15s ease-out; + + /* When the dropdown is open, we need to hack the box-shadow's position + to fit the whole dropdown menu component with padding-top + + MenuLabel height 56px + original padding-top 7px results to 63px + */ + padding: 63px 0 15px 0; + top: 0px; +} + +.menuItem { + color: var(--matterColor); + &:hover { + background-color: #f2f2f2; + } +} + +.menuText, +.menuTextReplaceCard { + @apply --marketplaceMessageFontStyles; + padding: 12px 8px; + color: var(--matterColor); + text-align: left; + font-weight: 500; + width: 80%; + + &:hover { + text-decoration: none; + } + + &:focus { + outline: 0; + background-color: #f2f2f2; + } +} + +.menuTextReplaceCard svg { + position: relative; + top: -2px; + width: 22px; + margin: 0 10px 0 4px; +} + +.menuDivider { + @apply --marketplaceH5FontStyles; + color: var(--matterColorAnti); + padding: 14px 24px 2px 24px; + margin: 0; +} + +.iconArrow { + stroke: var(--matterColor); + fill: var(--matterColor); + transition: all 0.2s ease-out; +} + +.IconArrowAnimation { + transform: rotate(180deg); +} + +.savedPaymentMethodDelete { + @apply --marketplaceH4FontStyles; + color: var(--matterColorAnti); + cursor: pointer; + margin-top: 14px; + margin-bottom: 10px; + + &:hover { + color: var(--matterColor); + } + + &:focus { + outline: none; + color: var(--matterColor); + } +} + +.modalButtonsWrapper { + padding-top: 80px; + display: flex; + justify-content: flex-end; + align-items: baseline; +} + +/* Title of the modal */ +.modalTitle { + @apply --marketplaceModalTitleStyles; + padding-top: 36px; +} + +/* Paragraph for the Modal */ +.modalMessage { + @apply --marketplaceModalParagraphStyles; + min-height: 200px; +} + +.cancelCardDelete { + color: var(--matterColorAnti); + cursor: pointer; + width: 50%; + text-align: right; + padding-right: 64px; + + &:hover { + color: var(--matterColor); + } +} diff --git a/src/components/SavedCardDetails/SavedCardDetails.example.js b/src/components/SavedCardDetails/SavedCardDetails.example.js new file mode 100644 index 00000000..150e5155 --- /dev/null +++ b/src/components/SavedCardDetails/SavedCardDetails.example.js @@ -0,0 +1,53 @@ +import { fakeIntl } from '../../util/test-data'; +import SavedCardDetails from './SavedCardDetails'; + +const noop = () => null; +const defaultProps = { + intl: fakeIntl, + onDeleteCard: noop, + onChange: noop, + onManageDisableScrolling: noop, +}; + +export const SavedCardDetailsExample = { + component: SavedCardDetails, + props: { + ...defaultProps, + card: { + brand: 'visa', + expirationMonth: 10, + expirationYear: 2050, + last4Digits: '3220', + }, + }, + group: 'payment methods', +}; + +export const SavedCardDetailsNoDelete = { + component: SavedCardDetails, + props: { + ...defaultProps, + card: { + brand: 'mastercard', + expirationMonth: 10, + expirationYear: 2050, + last4Digits: '3220', + }, + onDeleteCard: null, + }, + group: 'payment methods', +}; + +export const SavedCardDetailsExpired = { + component: SavedCardDetails, + props: { + ...defaultProps, + card: { + brand: 'amex', + expirationMonth: 7, + expirationYear: 2019, + last4Digits: '3220', + }, + }, + group: 'payment methods', +}; diff --git a/src/components/SavedCardDetails/SavedCardDetails.js b/src/components/SavedCardDetails/SavedCardDetails.js new file mode 100644 index 00000000..65efa14c --- /dev/null +++ b/src/components/SavedCardDetails/SavedCardDetails.js @@ -0,0 +1,240 @@ +import React, { useState } from 'react'; +import { bool, func, number, shape, string } from 'prop-types'; +import classNames from 'classnames'; +import { injectIntl, intlShape } from 'react-intl'; +import { + IconArrowHead, + IconCard, + IconClose, + IconCheckmark, + Button, + InlineTextButton, + Menu, + MenuLabel, + MenuItem, + MenuContent, + Modal, +} from '../../components'; +import css from './SavedCardDetails.css'; + +const DEFAULT_CARD = 'defaultCard'; +const REPLACE_CARD = 'replaceCard'; + +const SavedCardDetails = props => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [active, setActive] = useState(DEFAULT_CARD); + + const { + rootClassName, + className, + intl, + card, + onChange, + onDeleteCard, + onManageDisableScrolling, + deletePaymentMethodInProgress, + } = props; + + const { last4Digits, expirationMonth, expirationYear, brand } = card || {}; + const classes = classNames(rootClassName || css.root, className); + + const paymentMethodPlaceholderDesktop = intl.formatMessage( + { id: 'SavedCardDetails.savedPaymentMethodPlaceholderDesktop' }, + { lastFour: last4Digits } + ); + + const paymentMethodPlaceholderMobile = intl.formatMessage( + { id: 'SavedCardDetails.savedPaymentMethodPlaceholderMobile' }, + { lastFour: last4Digits } + ); + + const paymentMethodPlaceholder = ( + <> + {paymentMethodPlaceholderDesktop} + {paymentMethodPlaceholderMobile} + + ); + + const replaceCardText = intl.formatMessage({ + id: 'SavedCardDetails.replaceCardText', + }); + const replaceCard = ( + + {replaceCardText} + + ); + + const expiredCardText = intl.formatMessage( + { id: 'SavedCardDetails.expiredCardText' }, + { lastFour: last4Digits } + ); + const expiredText =
{expiredCardText}
; + + const isExpired = (expirationMonth, expirationYear) => { + const currentTime = new Date(); + const currentYear = currentTime.getFullYear(); + const currentMonth = currentTime.getMonth() + 1; //getMonth() method returns the month (from 0 to 11) + + if (expirationYear < currentYear) { + return true; + } else if (expirationYear === currentYear && expirationMonth < currentMonth) { + return true; + } + + return false; + }; + + const isCardExpired = + expirationMonth && expirationYear && isExpired(expirationMonth, expirationYear); + + const defaultCard = ( +
+ + {paymentMethodPlaceholder} + + {expirationMonth}/{expirationYear.toString().substring(2)} + +
+ ); + + const handleClick = item => e => { + // Clicking buttons inside a form will call submit + e.preventDefault(); + e.stopPropagation(); + + setActive(item); + setMenuOpen(false); + if (onChange) { + onChange(item); + } + }; + + const onToggleActive = isOpen => { + setMenuOpen(isOpen); + }; + + const handleDeleteCard = () => { + setIsModalOpen(true); + }; + + const iconArrowClassName = menuOpen ? css.IconArrowAnimation : null; + + const replaceCardTitle = intl.formatMessage({ + id: 'SavedCardDetails.replaceCardTitle', + }); + const removeCardModalTitle = intl.formatMessage({ id: 'SavedCardDetails.removeCardModalTitle' }); + const removeCardModalContent = intl.formatMessage({ + id: 'SavedCardDetails.removeCardModalContent', + }); + const cancel = intl.formatMessage({ id: 'SavedCardDetails.cancel' }); + const removeCard = intl.formatMessage({ id: 'SavedCardDetails.removeCard' }); + const deletePaymentMethod = intl.formatMessage({ id: 'SavedCardDetails.deletePaymentMethod' }); + + const showExpired = isCardExpired && active === DEFAULT_CARD; + + return ( +
+ + +
+ {active === DEFAULT_CARD ? defaultCard : replaceCard} + + + +
+
+ + + + + + {defaultCard} + + + + {replaceCardTitle} + + + + + {replaceCard} + + + +
+ {showExpired && !menuOpen ? expiredText : null} + + {onDeleteCard && active !== REPLACE_CARD ? ( + + + {deletePaymentMethod} + + ) : null} + + { + setIsModalOpen(false); + }} + contentClassName={css.modalContent} + onManageDisableScrolling={onManageDisableScrolling} + > +
+
{removeCardModalTitle}
+

{removeCardModalContent}

+
+
setIsModalOpen(false)} className={css.cancelCardDelete}> + {cancel} +
+ +
+
+
+
+ ); +}; + +SavedCardDetails.defaultProps = { + rootClassName: null, + className: null, + card: null, + onChange: null, + onDeleteCard: null, + deletePaymentMethodInProgress: false, +}; + +SavedCardDetails.propTypes = { + rootClassName: string, + className: string, + intl: intlShape.isRequired, + card: shape({ + brand: string.isRequired, + expirationMonth: number.isRequired, + expirationYear: number.isRequired, + last4Digits: string.isRequired, + }), + onChange: func, + onDeleteCard: func, + onManageDisableScrolling: func.isRequired, + deletePaymentMethodInProgress: bool, +}; + +export default injectIntl(SavedCardDetails); diff --git a/src/components/index.js b/src/components/index.js index 5aa72405..ba9a7c7e 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -128,6 +128,7 @@ export { default as OrderDiscussionPanel } from './OrderDiscussionPanel/OrderDis export { default as Page } from './Page/Page'; export { default as PriceFilter } from './PriceFilter/PriceFilter'; export { default as Reviews } from './Reviews/Reviews'; +export { default as SavedCardDetails } from './SavedCardDetails/SavedCardDetails'; export { default as SearchFilters } from './SearchFilters/SearchFilters'; export { default as SearchFiltersMobile } from './SearchFiltersMobile/SearchFiltersMobile'; export { default as SearchFiltersPanel } from './SearchFiltersPanel/SearchFiltersPanel'; diff --git a/src/examples.js b/src/examples.js index fbc7b6cb..5af40b8f 100644 --- a/src/examples.js +++ b/src/examples.js @@ -59,6 +59,7 @@ import * as RangeSlider from './components/RangeSlider/RangeSlider.example'; import * as ResponsiveImage from './components/ResponsiveImage/ResponsiveImage.example'; import * as ReviewRating from './components/ReviewRating/ReviewRating.example'; import * as Reviews from './components/Reviews/Reviews.example'; +import * as SavedCardDetails from './components/SavedCardDetails/SavedCardDetails.example'; import * as SectionThumbnailLinks from './components/SectionThumbnailLinks/SectionThumbnailLinks.example'; import * as SelectMultipleFilter from './components/SelectMultipleFilter/SelectMultipleFilter.example'; import * as StripeBankAccountTokenInputField from './components/StripeBankAccountTokenInputField/StripeBankAccountTokenInputField.example'; @@ -171,6 +172,7 @@ export { ReviewForm, ReviewRating, Reviews, + SavedCardDetails, SectionThumbnailLinks, SelectMultipleFilter, SendMessageForm, diff --git a/src/translations/en.json b/src/translations/en.json index 941e8eb7..7628f21c 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -684,6 +684,16 @@ "ReviewModal.description": "Reviews are an important part of the Saunatime community. Please share what went well and what could have been improved.", "ReviewModal.later": "Later", "ReviewModal.title": "Leave a review for {revieweeName}", + "SavedCardDetails.cancel": "Cancel", + "SavedCardDetails.deletePaymentMethod": "Delete 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.replaceCardText": "Credit or debit card", + "SavedCardDetails.replaceCardTitle": "Use another payment method", + "SavedCardDetails.savedPaymentMethodPlaceholderDesktop": "•••• •••• •••• {lastFour}", + "SavedCardDetails.savedPaymentMethodPlaceholderMobile": "…{lastFour}", "SearchFilters.amenitiesLabel": "Amenities", "SearchFilters.categoryLabel": "Category", "SearchFilters.filtersButtonLabel": "Filters", From 8ffa38bcc03a80cd9b7c95031fda922acc7a9353 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Thu, 25 Jul 2019 10:37:53 +0300 Subject: [PATCH 10/21] Move StripePaymentAddress to components --- .../StripePaymentAddress.css | 35 +++++ .../StripePaymentAddress.js | 120 +++++++++--------- src/components/index.js | 1 + .../StripePaymentForm/StripePaymentForm.css | 26 ---- .../StripePaymentForm/StripePaymentForm.js | 18 ++- 5 files changed, 109 insertions(+), 91 deletions(-) create mode 100644 src/components/StripePaymentAddress/StripePaymentAddress.css rename src/{forms/StripePaymentForm => components/StripePaymentAddress}/StripePaymentAddress.js (66%) diff --git a/src/components/StripePaymentAddress/StripePaymentAddress.css b/src/components/StripePaymentAddress/StripePaymentAddress.css new file mode 100644 index 00000000..887d06f4 --- /dev/null +++ b/src/components/StripePaymentAddress/StripePaymentAddress.css @@ -0,0 +1,35 @@ +@import '../../marketplace.css'; + +.root { + display: flex; + flex-direction: column; +} + +.paymentAddressField { + padding-top: 38px; +} + +.formRow { + /* This container uses flexbox layout */ + display: flex; + justify-content: space-between; + width: 100%; + + /* parent aka root is flexbox, this container takes all available space */ + flex-grow: 1; + flex-shrink: 0; + flex-wrap: wrap; + + @media (--viewportSmall) { + flex-wrap: no-wrap; + } +} + +.field { + width: 100%; + margin-top: 24px; + + @media (--viewportSmall) { + width: calc(50% - 12px); + } +} diff --git a/src/forms/StripePaymentForm/StripePaymentAddress.js b/src/components/StripePaymentAddress/StripePaymentAddress.js similarity index 66% rename from src/forms/StripePaymentForm/StripePaymentAddress.js rename to src/components/StripePaymentAddress/StripePaymentAddress.js index 14dbe1f2..b31dce65 100644 --- a/src/forms/StripePaymentForm/StripePaymentAddress.js +++ b/src/components/StripePaymentAddress/StripePaymentAddress.js @@ -6,7 +6,7 @@ import * as validators from '../../util/validators'; import getCountryCodes from '../../translations/countryCodes'; import { FieldTextInput, FieldSelect } from '../../components'; -import css from './StripePaymentForm.css'; +import css from './StripePaymentAddress.css'; const StripePaymentAddress = props => { const { className, intl, disabled, form, fieldId, card } = props; @@ -78,38 +78,39 @@ const StripePaymentAddress = props => { const countryCodes = getCountryCodes(config.locale); return ( -
- form.change('addressLine1', undefined)} - /> - - form.change('addressLine2', undefined)} - /> +
+
+ form.change('addressLine1', undefined)} + /> + form.change('addressLine2', undefined)} + /> +
{ id={`${fieldId}.city`} name="city" disabled={disabled} - className={css.city} + className={css.field} type="text" autoComplete="billing address-level2" label={cityLabel} @@ -132,38 +133,39 @@ const StripePaymentAddress = props => { onUnmount={() => form.change('city', undefined)} />
+
+ form.change('state', undefined)} + /> - form.change('state', undefined)} - /> - - - - {countryCodes.map(country => { - return ( - - ); - })} - + + + {countryCodes.map(country => { + return ( + + ); + })} + +
); }; diff --git a/src/components/index.js b/src/components/index.js index ba9a7c7e..ca116c47 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -139,6 +139,7 @@ export { default as SearchMapPriceLabel } from './SearchMapPriceLabel/SearchMapP export { default as SearchResultsPanel } from './SearchResultsPanel/SearchResultsPanel'; export { default as SelectMultipleFilter } from './SelectMultipleFilter/SelectMultipleFilter'; export { default as SelectSingleFilter } from './SelectSingleFilter/SelectSingleFilter'; +export { default as StripePaymentAddress } from './StripePaymentAddress/StripePaymentAddress'; export { default as UserCard } from './UserCard/UserCard'; ////////////////////////////////////////////// diff --git a/src/forms/StripePaymentForm/StripePaymentForm.css b/src/forms/StripePaymentForm/StripePaymentForm.css index 786ef362..d3eb82be 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.css +++ b/src/forms/StripePaymentForm/StripePaymentForm.css @@ -143,29 +143,3 @@ .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.js b/src/forms/StripePaymentForm/StripePaymentForm.js index b9025536..98b6c93d 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.js +++ b/src/forms/StripePaymentForm/StripePaymentForm.js @@ -9,8 +9,7 @@ import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { Form as FinalForm } from 'react-final-form'; import classNames from 'classnames'; import config from '../../config'; -import { Form, PrimaryButton, FieldTextInput } from '../../components'; -import StripePaymentAddress from './StripePaymentAddress'; +import { Form, PrimaryButton, FieldTextInput, StripePaymentAddress } from '../../components'; import css from './StripePaymentForm.css'; /** @@ -105,10 +104,11 @@ class StripePaymentForm extends Component { } if (config.stripe.publishableKey) { + const { onStripeInitialized, hasHandledCardPayment, hasDefaultPaymentMethod } = this.props; this.stripe = window.Stripe(config.stripe.publishableKey); - this.props.onStripeInitialized(this.stripe); + onStripeInitialized(this.stripe); - if (!this.props.hasHandledCardPayment) { + if (!(hasHandledCardPayment || hasDefaultPaymentMethod)) { const elements = this.stripe.elements(stripeElementsOptions); this.card = elements.create('card', { style: cardStyles }); this.card.mount(this.cardContainer); @@ -182,11 +182,13 @@ class StripePaymentForm extends Component { handleSubmit, form, hasHandledCardPayment, + hasDefaultPaymentMethod, } = formRenderProps; this.finalFormAPI = form; - const billingDetailsNeeded = !(confirmPaymentError || hasHandledCardPayment); - const cardInputNeedsAttention = !(hasHandledCardPayment || this.state.cardValueValid); + const billingDetailsKnown = hasHandledCardPayment || hasDefaultPaymentMethod; + const billingDetailsNeeded = !(billingDetailsKnown || confirmPaymentError); + const cardInputNeedsAttention = !(billingDetailsKnown || this.state.cardValueValid); const submitDisabled = invalid || cardInputNeedsAttention || submitInProgress; const hasCardError = this.state.error && !submitInProgress; const hasPaymentErrors = handleCardPaymentError || confirmPaymentError; @@ -276,6 +278,8 @@ class StripePaymentForm extends Component { {billingAddress}
+ ) : hasDefaultPaymentMethod ? ( +
This will contain {``} component at some point.
) : null} {initiateOrderError ? ( @@ -335,6 +339,7 @@ StripePaymentForm.defaultProps = { inProgress: false, showInitialMessageInput: true, hasHandledCardPayment: false, + hasDefaultPaymentMethod: false, initiateOrderError: null, handleCardPaymentError: null, confirmPaymentError: null, @@ -354,6 +359,7 @@ StripePaymentForm.propTypes = { authorDisplayName: string.isRequired, showInitialMessageInput: bool, hasHandledCardPayment: bool, + hasDefaultPaymentMethod: bool, }; export default injectIntl(StripePaymentForm); From d4c265c0145b7fc3afa20933fc9abc92a528ad65 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Thu, 25 Jul 2019 10:39:03 +0300 Subject: [PATCH 11/21] Create PaymentMethodsForm --- .../PaymentMethodsForm/PaymentMethodsForm.css | 165 ++++++++++ .../PaymentMethodsForm/PaymentMethodsForm.js | 307 ++++++++++++++++++ .../StripePaymentForm/StripePaymentForm.css | 87 ++++- .../StripePaymentForm/StripePaymentForm.js | 287 ++++++++++++---- src/forms/index.js | 1 + src/translations/de.json | 2 +- src/translations/en.json | 15 +- src/translations/es.json | 2 +- src/translations/fr.json | 2 +- 9 files changed, 789 insertions(+), 79 deletions(-) create mode 100644 src/forms/PaymentMethodsForm/PaymentMethodsForm.css create mode 100644 src/forms/PaymentMethodsForm/PaymentMethodsForm.js diff --git a/src/forms/PaymentMethodsForm/PaymentMethodsForm.css b/src/forms/PaymentMethodsForm/PaymentMethodsForm.css new file mode 100644 index 00000000..66779b95 --- /dev/null +++ b/src/forms/PaymentMethodsForm/PaymentMethodsForm.css @@ -0,0 +1,165 @@ +@import '../../marketplace.css'; + +.root { + display: flex; + flex-direction: column; + margin-top: 40px; +} + +.card { + @apply --marketplaceInputStyles; + + /* Layout */ + padding: 8px 0 6px 0; + height: 36px; + + /* Border */ + border-bottom-color: var(--attentionColor); + + @media (--viewportMedium) { + height: 35px; + } + @media (--viewportLarge) { + height: 38px; + padding: 6px 0 14px 0; + } +} + +.cardSuccess { + border-bottom-color: var(--successColor); +} + +.cardError { + 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: 3px; + padding-bottom: 2px; + + @media (--viewportMedium) { + margin: 0 0 26px 0; + } +} + +.paymentLabel { + margin: 0; + + @media (--viewportMedium) { + margin-bottom: 2px; + } + @media (--viewportLarge) { + margin-bottom: 4px; + } +} + +.messageHeading { + color: var(--matterColorAnti); + margin: 40px 0 14px 0; + + padding-top: 4px; + padding-bottom: 4px; + + @media (--viewportMedium) { + margin: 41px 0 26px 0; + } + @media (--viewportLarge) { + margin: 40px 0 26px 0; + } +} + +.messageLabel { + margin: 0 0 5px 0; + + @media (--viewportMedium) { + margin: 0 0 6px 0; + } +} + +.messageOptional { + color: var(--matterColorAnti); +} + +.message { + border-bottom-color: var(--matterColorAnti); +} + +.submitContainer { + margin-top: auto; + margin-top: 48px; + @media (--viewportLarge) { + margin-top: 72px; + } +} + +.infoText { + @apply --marketplaceH5FontStyles; + color: var(--matterColorAnti); + padding: 0; + margin: 14px 0 0 0; +} + +.submitButton { + margin-top: 22px; + + @media (--viewportMedium) { + margin-top: 26px; + } + @media (--viewportLarge) { + margin-top: 17px; + } +} + +.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/PaymentMethodsForm/PaymentMethodsForm.js b/src/forms/PaymentMethodsForm/PaymentMethodsForm.js new file mode 100644 index 00000000..632f2c5c --- /dev/null +++ b/src/forms/PaymentMethodsForm/PaymentMethodsForm.js @@ -0,0 +1,307 @@ +/** + * Note: This form is using card from Stripe Elements https://stripe.com/docs/stripe-js#elements + * Card is not a Final Form field so it's not available trough Final Form. + * It's also handled separately in handleSubmit function. + */ +import React, { Component } from 'react'; +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 { Form, PrimaryButton, FieldTextInput, StripePaymentAddress } from '../../components'; +import css from './PaymentMethodsForm.css'; + +/** + * Translate a Stripe API error object. + * + * To keep up with possible keys from the Stripe API, see: + * + * https://stripe.com/docs/api#errors + * + * Note that at least at moment, the above link doesn't list all the + * error codes that the API returns. + * + * @param {Object} intl - react-intl object from injectIntl + * @param {Object} stripeError - error object from Stripe API + * + * @return {String} translation message for the specific Stripe error, + * or the given error message (not translated) if the specific error + * type/code is not defined in the translations + * + */ +const stripeErrorTranslation = (intl, stripeError) => { + const { message, code, type } = stripeError; + + if (!code || !type) { + // Not a proper Stripe error object + return intl.formatMessage({ id: 'PaymentMethodsForm.genericError' }); + } + + const translationId = + type === 'validation_error' + ? `PaymentMethodsForm.stripe.validation_error.${code}` + : `PaymentMethodsForm.stripe.${type}`; + + return intl.formatMessage({ + id: translationId, + defaultMessage: message, + }); +}; + +const stripeElementsOptions = { + fonts: [ + { + family: 'sofiapro', + fontSmoothing: 'antialiased', + src: + 'local("sofiapro"), local("SofiaPro"), local("Sofia Pro"), url("https://assets-sharetribecom.sharetribe.com/webfonts/sofiapro/sofiapro-medium-webfont.woff2") format("woff2")', + }, + ], +}; + +const cardStyles = { + base: { + fontFamily: '"sofiapro", Helvetica, Arial, sans-serif', + fontSize: '18px', + fontSmoothing: 'antialiased', + lineHeight: '24px', + letterSpacing: '-0.1px', + color: '#4A4A4A', + '::placeholder': { + color: '#B2B2B2', + }, + }, +}; + +const initialState = { + error: null, + cardValueValid: false, +}; + +/** + * Payment methods 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 handle card setup. `stripe.handleCardSetup` + * may ask more details from cardholder if 3D security steps are needed. + * + * See: https://stripe.com/docs/payments/payment-intents + * https://stripe.com/docs/elements + */ +class PaymentMethodsForm extends Component { + constructor(props) { + super(props); + this.state = initialState; + this.handleCardValueChange = this.handleCardValueChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.paymentForm = this.paymentForm.bind(this); + this.finalFormAPI = null; + this.stripe = null; + } + + componentDidMount() { + if (!window.Stripe) { + throw new Error('Stripe must be loaded for PaymentMethodsForm'); + } + + 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' } } }); + } + }); + } + } + componentWillUnmount() { + if (this.card) { + this.card.removeEventListener('change', this.handleCardValueChange); + this.card.unmount(); + } + } + handleCardValueChange(event) { + const { intl } = this.props; + const { error, complete } = event; + + const postalCode = event.value.postalCode; + if (this.finalFormAPI) { + this.finalFormAPI.change('postal', postalCode); + } + + this.setState(prevState => { + return { + error: error ? stripeErrorTranslation(intl, error) : null, + cardValueValid: complete, + }; + }); + } + handleSubmit(values) { + const { onSubmit, inProgress, formId } = this.props; + const cardInputNeedsAttention = !this.state.cardValueValid; + + if (inProgress || cardInputNeedsAttention) { + // Already submitting or card value incomplete/invalid + return; + } + + const params = { + stripe: this.stripe, + card: this.card, + formId, + formValues: values, + }; + + onSubmit(params); + } + + paymentForm(formRenderProps) { + const { + className, + rootClassName, + inProgress: submitInProgress, + formId, + intl, + invalid, + handleSubmit, + addPaymentMethodError, + deletePaymentMethodError, + createStripeCustomerError, + handleCardSetupError, + form, + } = formRenderProps; + + this.finalFormAPI = form; + const cardInputNeedsAttention = !this.state.cardValueValid; + const submitDisabled = invalid || cardInputNeedsAttention || submitInProgress; + const hasCardError = this.state.error && !submitInProgress; + const classes = classNames(rootClassName || css.root, className); + const cardClasses = classNames(css.card, { + [css.cardSuccess]: this.state.cardValueValid, + [css.cardError]: hasCardError, + }); + + const hasErrors = + addPaymentMethodError || + deletePaymentMethodError || + createStripeCustomerError || + handleCardSetupError; + + const errorMessage = intl.formatMessage({ id: 'PaymentMethodsForm.genericError' }); + + const billingDetailsNameLabel = intl.formatMessage({ + id: 'PaymentMethodsForm.billingDetailsNameLabel', + }); + + const billingDetailsNamePlaceholder = intl.formatMessage({ + id: 'PaymentMethodsForm.billingDetailsNamePlaceholder', + }); + + const infoText = intl.formatMessage({ + id: 'PaymentMethodsForm.infoText', + }); + + // Stripe recommends asking billing address. + // In PaymentMethodsForm, we send name and email as billing details, but address only if it exists. + const billingAddress = ( + + ); + + const hasStripeKey = config.stripe.publishableKey; + + return hasStripeKey ? ( +
+ + +
{ + this.cardContainer = el; + }} + /> +
{infoText}
+ {hasCardError ? {this.state.error} : null} +
+

+ +

+ + + + {billingAddress} +
+ +
+ {hasErrors ? ( + + {hasErrors.message ? hasErrors.message : errorMessage} + + ) : null} + + + +
+ + ) : ( +
+ +
+ ); + } + + render() { + const { onSubmit, ...rest } = this.props; + return ; + } +} + +PaymentMethodsForm.defaultProps = { + className: null, + rootClassName: null, + inProgress: false, + handleSubmit: null, + invalid: false, + addPaymentMethodError: null, + deletePaymentMethodError: null, + createStripeCustomerError: null, + handleCardSetupError: null, + form: null, +}; + +PaymentMethodsForm.propTypes = { + formId: string, + intl: intlShape.isRequired, + invalid: bool, + handleSubmit: func, + addPaymentMethodError: object, + deletePaymentMethodError: object, + createStripeCustomerError: object, + handleCardSetupError: object, + form: object, +}; + +export default injectIntl(PaymentMethodsForm); diff --git a/src/forms/StripePaymentForm/StripePaymentForm.css b/src/forms/StripePaymentForm/StripePaymentForm.css index d3eb82be..c3537ea2 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.css +++ b/src/forms/StripePaymentForm/StripePaymentForm.css @@ -16,7 +16,7 @@ border-bottom-color: var(--attentionColor); @media (--viewportMedium) { - height: 35px; + height: 38px; } @media (--viewportLarge) { height: 38px; @@ -42,26 +42,80 @@ } .paymentHeading { - margin: 0 0 14px 0; + margin: 0 0 12px 0; color: var(--matterColorAnti); - padding-top: 8px; - padding-bottom: 0px; + padding-top: 4px; + padding-bottom: 2px; @media (--viewportMedium) { - margin: 0 0 26px 0; + margin: 0 0 16px 0; + padding-top: 4px; + padding-bottom: 4px; + } +} + +.paymentMethodSelector { + margin-bottom: 36px; + + @media (--viewportMedium) { + margin-bottom: 40px; + } +} + +.saveForLaterUse { + padding-top: 6px; + margin-bottom: 36px; + + @media (--viewportMedium) { + padding-top: 8px; + margin-top: 0px; + margin-bottom: 48px; + } +} + +.saveForLaterUseCheckbox { + display: inline-block; + padding: 2px 0 3px 0; + + @media (--viewportMedium) { + padding: 1px 0 0px 0; + } +} + +.saveForLaterUseLabel { + @apply --marketplaceH5FontStyles; + color: var(--matterColor); + margin-top: 3px; + margin-bottom: 0px; + cursor: pointer; +} + +.saveForLaterUseLegalInfo { + @apply --marketplaceH5FontStyles; + color: var(--matterColorAnti); + display: inline-block; + padding: 0px 0 0px 26px; + margin-top: -2px; + margin-bottom: 2px; + + @media (--viewportMedium) { + margin-top: -1px; + margin-bottom: 1px; } } .billingHeading { - margin: 0 0 14px 0; + margin: 0 0 12px 0; color: var(--matterColorAnti); - padding-top: 7px; - padding-bottom: 1px; + padding-top: 4px; + padding-bottom: 2px; @media (--viewportMedium) { - margin: 0 0 26px 0; + margin: 0 0 24px 0; + padding-top: 4px; + padding-bottom: 4px; } } @@ -69,25 +123,22 @@ margin: 0; @media (--viewportMedium) { + margin-top: 0px; margin-bottom: 2px; } - @media (--viewportLarge) { - margin-bottom: 4px; - } } .messageHeading { color: var(--matterColorAnti); - margin: 40px 0 14px 0; + margin: 36px 0 12px 0; padding-top: 4px; - padding-bottom: 4px; + padding-bottom: 2px; @media (--viewportMedium) { - margin: 41px 0 26px 0; - } - @media (--viewportLarge) { - margin: 40px 0 26px 0; + margin: 24px 0 24px 0; + padding-top: 4px; + padding-bottom: 4px; } } diff --git a/src/forms/StripePaymentForm/StripePaymentForm.js b/src/forms/StripePaymentForm/StripePaymentForm.js index 98b6c93d..bf5e1059 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.js +++ b/src/forms/StripePaymentForm/StripePaymentForm.js @@ -9,7 +9,18 @@ import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { Form as FinalForm } from 'react-final-form'; import classNames from 'classnames'; import config from '../../config'; -import { Form, PrimaryButton, FieldTextInput, StripePaymentAddress } from '../../components'; +import { propTypes } from '../../util/types'; +import { ensurePaymentMethodCard } from '../../util/data'; + +import { + Form, + PrimaryButton, + FieldCheckbox, + FieldTextInput, + IconSpinner, + SavedCardDetails, + StripePaymentAddress, +} from '../../components'; import css from './StripePaymentForm.css'; /** @@ -74,9 +85,92 @@ const cardStyles = { }, }; +const OneTimePaymentWithCardElement = props => { + const { cardClasses, formId, handleStripeElementRef, hasCardError, error, label, intl } = props; + const labelText = + label || intl.formatMessage({ id: 'StripePaymentForm.saveAfterOnetimePayment' }); + return ( + + +
+ {hasCardError ? {error} : null} +
+ + + + +
+ + ); +}; + +const PaymentMethodSelector = props => { + const { + cardClasses, + formId, + changePaymentMethod, + defaultPaymentMethod, + handleStripeElementRef, + hasCardError, + error, + paymentMethod, + intl, + } = props; + const last4Digits = defaultPaymentMethod.attributes.card.last4Digits; + const labelText = intl.formatMessage( + { id: 'StripePaymentForm.replaceAfterOnetimePayment' }, + { last4Digits } + ); + + return ( + +

+ +

+ + {paymentMethod === 'replaceCard' ? ( + + ) : null} +
+ ); +}; + +const getPaymentMethod = (selectedPaymentMethod, hasDefaultPaymentMethod) => { + return selectedPaymentMethod == null && hasDefaultPaymentMethod + ? 'defaultCard' + : selectedPaymentMethod == null + ? 'onetimeCardPayment' + : selectedPaymentMethod; +}; + const initialState = { error: null, cardValueValid: false, + // The mode can be 'onetimePayment', 'defaultCard', or 'replaceCard' + // Check SavedCardDetails component for more information + paymentMethod: null, }; /** @@ -96,40 +190,78 @@ class StripePaymentForm extends Component { this.handleCardValueChange = this.handleCardValueChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.paymentForm = this.paymentForm.bind(this); + this.initializeStripeElement = this.initializeStripeElement.bind(this); + this.handleStripeElementRef = this.handleStripeElementRef.bind(this); + this.changePaymentMethod = this.changePaymentMethod.bind(this); this.finalFormAPI = null; + this.cardContainer = null; } + componentDidMount() { if (!window.Stripe) { throw new Error('Stripe must be loaded for StripePaymentForm'); } if (config.stripe.publishableKey) { - const { onStripeInitialized, hasHandledCardPayment, hasDefaultPaymentMethod } = this.props; + const { + onStripeInitialized, + hasHandledCardPayment, + defaultPaymentMethod, + loadingData, + } = this.props; this.stripe = window.Stripe(config.stripe.publishableKey); onStripeInitialized(this.stripe); - if (!(hasHandledCardPayment || hasDefaultPaymentMethod)) { - 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 (!(hasHandledCardPayment || defaultPaymentMethod || loadingData)) { + this.initializeStripeElement(); + } + } + } + + componentWillUnmount() { + if (this.card) { + this.card.removeEventListener('change', this.handleCardValueChange); + this.card.unmount(); + this.card = null; + } + } + + initializeStripeElement(element) { + const elements = this.stripe.elements(stripeElementsOptions); + + if (!this.card) { + this.card = elements.create('card', { style: cardStyles }); + this.card.mount(element || this.cardContainer); + this.card.addEventListener('change', this.handleCardValueChange); + // EventListener is the only way to simulate breakpoints with Stripe. + window.addEventListener('resize', () => { + if (this.card) { if (window.innerWidth < 1024) { this.card.update({ style: { base: { fontSize: '18px', lineHeight: '24px' } } }); } else { this.card.update({ style: { base: { fontSize: '20px', lineHeight: '32px' } } }); } - }); - } + } + }); } } - componentWillUnmount() { - if (this.card) { + + changePaymentMethod(changedTo) { + if (this.card && changedTo === 'defaultCard') { this.card.removeEventListener('change', this.handleCardValueChange); this.card.unmount(); + this.card = null; + } + this.setState({ paymentMethod: changedTo }); + } + + handleStripeElementRef(el) { + this.cardContainer = el; + if (this.stripe && el) { + this.initializeStripeElement(el); } } + handleCardValueChange(event) { const { intl } = this.props; const { error, complete } = event; @@ -147,11 +279,19 @@ class StripePaymentForm extends Component { }); } handleSubmit(values) { - const { onSubmit, inProgress, formId, hasHandledCardPayment } = this.props; + const { + onSubmit, + inProgress, + formId, + hasHandledCardPayment, + defaultPaymentMethod, + } = this.props; const { initialMessage } = values; - const cardInputNeedsAttention = !(hasHandledCardPayment || this.state.cardValueValid); + const { cardValueValid, paymentMethod } = this.state; + const billingDetailsKnown = hasHandledCardPayment || defaultPaymentMethod; + const onetimePaymentNeedsAttention = !billingDetailsKnown && !cardValueValid; - if (inProgress || cardInputNeedsAttention) { + if (inProgress || onetimePaymentNeedsAttention) { // Already submitting or card value incomplete/invalid return; } @@ -161,6 +301,10 @@ class StripePaymentForm extends Component { card: this.card, formId, formValues: values, + paymentMethod: getPaymentMethod( + paymentMethod, + ensurePaymentMethodCard(defaultPaymentMethod).id + ), }; onSubmit(params); } @@ -170,6 +314,7 @@ class StripePaymentForm extends Component { className, rootClassName, inProgress: submitInProgress, + loadingData, formId, paymentInfo, authorDisplayName, @@ -182,14 +327,16 @@ class StripePaymentForm extends Component { handleSubmit, form, hasHandledCardPayment, - hasDefaultPaymentMethod, + defaultPaymentMethod, } = formRenderProps; this.finalFormAPI = form; - const billingDetailsKnown = hasHandledCardPayment || hasDefaultPaymentMethod; - const billingDetailsNeeded = !(billingDetailsKnown || confirmPaymentError); - const cardInputNeedsAttention = !(billingDetailsKnown || this.state.cardValueValid); - const submitDisabled = invalid || cardInputNeedsAttention || submitInProgress; + + const ensuredDefaultPaymentMethod = ensurePaymentMethodCard(defaultPaymentMethod); + const billingDetailsNeeded = !(hasHandledCardPayment || confirmPaymentError); + const billingDetailsKnown = hasHandledCardPayment || ensuredDefaultPaymentMethod; + const onetimePaymentNeedsAttention = !billingDetailsKnown && !this.state.cardValueValid; + const submitDisabled = invalid || onetimePaymentNeedsAttention || submitInProgress; const hasCardError = this.state.error && !submitInProgress; const hasPaymentErrors = handleCardPaymentError || confirmPaymentError; const classes = classNames(rootClassName || css.root, className); @@ -240,46 +387,70 @@ class StripePaymentForm extends Component { ); const hasStripeKey = config.stripe.publishableKey; - + const showPaymentMethodSelector = ensuredDefaultPaymentMethod.id; + const selectedPaymentMethod = getPaymentMethod( + this.state.paymentMethod, + showPaymentMethodSelector + ); + const showOnetimePaymentFields = ['onetimeCardPayment', 'replaceCard'].includes( + selectedPaymentMethod + ); return hasStripeKey ? (
- {billingDetailsNeeded ? ( + {billingDetailsNeeded && !loadingData ? ( -

- -

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

- -

- - + ) : ( + +

+ +

+ +
+ )} - {billingAddress} -
+ {showOnetimePaymentFields ? ( +
+

+ +

+ + + + {billingAddress} +
+ ) : null} - ) : hasDefaultPaymentMethod ? ( -
This will contain {``} component at some point.
+ ) : loadingData ? ( +

+ +

) : null} {initiateOrderError ? ( @@ -337,9 +508,10 @@ StripePaymentForm.defaultProps = { className: null, rootClassName: null, inProgress: false, + loadingData: false, showInitialMessageInput: true, hasHandledCardPayment: false, - hasDefaultPaymentMethod: false, + defaultPaymentMethod: null, initiateOrderError: null, handleCardPaymentError: null, confirmPaymentError: null, @@ -349,6 +521,7 @@ StripePaymentForm.propTypes = { className: string, rootClassName: string, inProgress: bool, + loadingData: bool, initiateOrderError: object, handleCardPaymentError: object, confirmPaymentError: object, @@ -359,7 +532,7 @@ StripePaymentForm.propTypes = { authorDisplayName: string.isRequired, showInitialMessageInput: bool, hasHandledCardPayment: bool, - hasDefaultPaymentMethod: bool, + defaultPaymentMethod: propTypes.defaultPaymentMethod, }; export default injectIntl(StripePaymentForm); diff --git a/src/forms/index.js b/src/forms/index.js index f3607e97..aeb7a1a1 100644 --- a/src/forms/index.js +++ b/src/forms/index.js @@ -15,6 +15,7 @@ export { default as LoginForm } from './LoginForm/LoginForm'; export { default as PasswordChangeForm } from './PasswordChangeForm/PasswordChangeForm'; export { default as PasswordRecoveryForm } from './PasswordRecoveryForm/PasswordRecoveryForm'; export { default as PasswordResetForm } from './PasswordResetForm/PasswordResetForm'; +export { default as PaymentMethodsForm } from './PaymentMethodsForm/PaymentMethodsForm'; export { default as PayoutDetailsForm } from './PayoutDetailsForm/PayoutDetailsForm'; export { default as PriceFilterForm } from './PriceFilterForm/PriceFilterForm'; export { default as ProfileSettingsForm } from './ProfileSettingsForm/ProfileSettingsForm'; diff --git a/src/translations/de.json b/src/translations/de.json index 30231704..c7187b1d 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -773,13 +773,13 @@ "StripeBankAccountTokenInputField.transitNumber.placeholder": "Transit Number eingeben…", "StripeBankAccountTokenInputField.transitNumber.required": "Transit Number wird benötigt", "StripeBankAccountTokenInputField.unsupportedCountry": "Nicht unterstützes Land: {country}", - "StripePaymentForm.creditCardDetails": "Kreditkartendetails", "StripePaymentForm.genericError": "Zahlungsinformationen konnten nicht verarbeitet werden. Bitte erneut versuchen.", "StripePaymentForm.messageHeading": "Nachricht", "StripePaymentForm.messageLabel": "Hallo an deine(n) Gastgeber(in) {messageOptionalText} sagen", "StripePaymentForm.messageOptionalText": "• optional", "StripePaymentForm.messagePlaceholder": "Hallo {name}! Ich freue mich auf…", "StripePaymentForm.missingStripeKey": "Der öffentliche Stripe API-schlüssel (\"Stripe publishable key\") wurde noch nicht für diesen Marktplatz konfiguriert. Leider kann der Eintrag nicht gebucht werden.", + "StripePaymentForm.paymentCardDetails": "Kreditkartendetails", "StripePaymentForm.paymentHeading": "Zahlung", "StripePaymentForm.stripe.api_connection_error": "Fehler bei Verbindung mit Stripe API.", "StripePaymentForm.stripe.api_error": "Fehler bei Stripe API.", diff --git a/src/translations/en.json b/src/translations/en.json index 7628f21c..a4b95353 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -466,6 +466,15 @@ "PasswordResetPage.recoveryLinkText": "password recovery page", "PasswordResetPage.resetFailed": "Reset failed. Please try again.", "PasswordResetPage.title": "Reset password", + "PaymentMethodsForm.billingDetails": "Billing details", + "PaymentMethodsForm.billingDetailsNameLabel": "Card holder's name", + "PaymentMethodsForm.billingDetailsNamePlaceholder": "Enter your name…", + "PaymentMethodsForm.paymentCardDetails": "Payment card details", + "PaymentMethodsForm.genericError": "Could not handle payment data. Please try again.", + "PaymentMethodsForm.handleCardPaymentError": "We are unable to authenticate your payment method. Please check your authentication details and try again.", + "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", "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", @@ -825,7 +834,6 @@ "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", @@ -833,7 +841,12 @@ "StripePaymentForm.messageOptionalText": "• optional", "StripePaymentForm.messagePlaceholder": "Hello {name}! I'm looking forward to…", "StripePaymentForm.missingStripeKey": "Stripe publishable key has not been configured to this marketplace. Unfortunately, booking the listing is not possible.", + "StripePaymentForm.paymentCardDetails": "Payment card details", "StripePaymentForm.paymentHeading": "Payment", + "StripePaymentForm.payWithHeading": "Pay with", + "StripePaymentForm.replaceAfterOnetimePayment": "Replace card …{last4Digits} with the new card for future bookings", + "StripePaymentForm.saveAfterOnetimePayment": "Save card details for future bookings", + "StripePaymentForm.saveforLaterUseLegalInfo": "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.", "StripePaymentForm.stripe.api_connection_error": "Could not connect to Stripe API.", "StripePaymentForm.stripe.api_error": "Error in Stripe API.", "StripePaymentForm.stripe.authentication_error": "Could not authenticate with Stripe.", diff --git a/src/translations/es.json b/src/translations/es.json index 6e892c35..df31b956 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -815,7 +815,6 @@ "StripePaymentForm.billingDetailsNameLabel": "Nombre del titular de la tarjeta", "StripePaymentForm.billingDetailsNamePlaceholder": "Escribe el nombre…", "StripePaymentForm.confirmPaymentError": "El pago ha sido realizado pero no hemos podido confirmar la reserva. Por favor intenta confirmar la solicitud de reserva otra vez! Si la reserva no se confirma a tiempo, el pago será reembolsado en su totalidad.", - "StripePaymentForm.creditCardDetails": "Datos de tarjeta de crédito", "StripePaymentForm.genericError": "Ups, ha habido algún problema con la información de pagos. Por favor, inténtalo de nuevo.", "StripePaymentForm.handleCardPaymentError": "No hemos podido autenticar el método de pago. Por favor checa los detalles de autenticación e intenta de nuevo.", "StripePaymentForm.messageHeading": "Mensaje", @@ -823,6 +822,7 @@ "StripePaymentForm.messageOptionalText": "• opcional", "StripePaymentForm.messagePlaceholder": "Hola {name}! Acabo de solicitar…", "StripePaymentForm.missingStripeKey": "La clave pública de Stripe no se ha configurado en este marketplace. Por esta razón, no es posible hacer reservas.", + "StripePaymentForm.paymentCardDetails": "Datos de tarjeta de crédito", "StripePaymentForm.paymentHeading": "Pago", "StripePaymentForm.stripe.api_connection_error": "No se consigue conectar con API de Stripe.", "StripePaymentForm.stripe.api_error": "Error de conexión con Stripe.", diff --git a/src/translations/fr.json b/src/translations/fr.json index b46adf91..bc671f77 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -815,7 +815,6 @@ "StripePaymentForm.billingDetailsNameLabel": "Nom du porteur de la carte", "StripePaymentForm.billingDetailsNamePlaceholder": "Entrez votre nom…", "StripePaymentForm.confirmPaymentError": "Le paiement a été réalisé mais nous n'avons pu confirmer la réservation. Veuillez essayez de réserver de nouveau ! Si la réservation n'est pas confirmée dans les temps, le paiement sera entièrement remboursé.", - "StripePaymentForm.creditCardDetails": "Votre carte bancaire", "StripePaymentForm.genericError": "Impossible de traiter ces détails de paiement. Veuillez essayer de nouveau.", "StripePaymentForm.handleCardPaymentError": "Nous n'avons pu authentifier votre méthode de paiement. Veuillez vérifier vos détails d'authentifications et essayer de nouveau.", "StripePaymentForm.messageHeading": "Message", @@ -823,6 +822,7 @@ "StripePaymentForm.messageOptionalText": "• optionel", "StripePaymentForm.messagePlaceholder": "Bonjour {name} ! J'aimerais…", "StripePaymentForm.missingStripeKey": "La \"Stripe publishable key\" n'a pas été configurée pour cette place de marché. Hélas, réserver cette annonce n'est pas possible pour le moment.", + "StripePaymentForm.paymentCardDetails": "Votre carte bancaire", "StripePaymentForm.paymentHeading": "Paiement", "StripePaymentForm.stripe.api_connection_error": "Impossible de se connecter à l'API Stripe.", "StripePaymentForm.stripe.api_error": "Erreur avec l'API Stripe.", From 905f99043bd558e187b3005e9b8f56ef7c442163 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Thu, 25 Jul 2019 10:44:18 +0300 Subject: [PATCH 12/21] Create PaymentMethodsPage --- .../PaymentMethodsPage/PaymentMethodsPage.css | 28 ++ .../PaymentMethodsPage.duck.js | 106 ++++++ .../PaymentMethodsPage/PaymentMethodsPage.js | 306 ++++++++++++++++++ src/containers/index.js | 1 + src/containers/reducers.js | 2 + src/routeConfiguration.js | 10 + src/translations/en.json | 16 +- 7 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 src/containers/PaymentMethodsPage/PaymentMethodsPage.css create mode 100644 src/containers/PaymentMethodsPage/PaymentMethodsPage.duck.js create mode 100644 src/containers/PaymentMethodsPage/PaymentMethodsPage.js 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} + + )} +
+
+ +