diff --git a/src/containers/CheckoutPage/CheckoutPage.css b/src/containers/CheckoutPage/CheckoutPage.css index 8d8e59fb..9aca1057 100644 --- a/src/containers/CheckoutPage/CheckoutPage.css +++ b/src/containers/CheckoutPage/CheckoutPage.css @@ -5,6 +5,10 @@ --logoHeightDesktop: 27px; } +.loading { + margin: 24px; +} + /* Dummy Topbar */ .topbar { @@ -250,6 +254,14 @@ color: var(--failColor); } +.speculateError { + color: var(--failColor); + + @media (--viewportLarge) { + margin: 0 48px; + } +} + .paymentForm { flex-grow: 1; } diff --git a/src/containers/CheckoutPage/CheckoutPage.duck.js b/src/containers/CheckoutPage/CheckoutPage.duck.js index 7f2dc67b..411bb404 100644 --- a/src/containers/CheckoutPage/CheckoutPage.duck.js +++ b/src/containers/CheckoutPage/CheckoutPage.duck.js @@ -1,4 +1,6 @@ import { pick } from 'lodash'; +import { updatedEntities, denormalisedEntities } from '../../util/data'; +import * as propTypes from '../../util/propTypes'; // ================ Action types ================ // @@ -8,25 +10,54 @@ export const INITIATE_ORDER_REQUEST = 'app/CheckoutPage/INITIATE_ORDER_REQUEST'; export const INITIATE_ORDER_SUCCESS = 'app/CheckoutPage/INITIATE_ORDER_SUCCESS'; export const INITIATE_ORDER_ERROR = 'app/CheckoutPage/INITIATE_ORDER_ERROR'; +export const SPECULATE_TRANSACTION_REQUEST = 'app/ListingPage/SPECULATE_TRANSACTION_REQUEST'; +export const SPECULATE_TRANSACTION_SUCCESS = 'app/ListingPage/SPECULATE_TRANSACTION_SUCCESS'; +export const SPECULATE_TRANSACTION_ERROR = 'app/ListingPage/SPECULATE_TRANSACTION_ERROR'; + // ================ Reducer ================ // const initialState = { - bookingDates: null, - initiateOrderError: null, listing: null, + bookingDates: null, + speculateTransactionInProgress: false, + speculateTransactionError: null, + speculatedTransaction: null, + initiateOrderError: null, }; export default function checkoutPageReducer(state = initialState, action = {}) { const { type, payload } = action; switch (type) { case SET_INITAL_VALUES: - return { ...state, ...payload }; + return { ...initialState, ...payload }; + + case SPECULATE_TRANSACTION_REQUEST: + return { + ...state, + speculateTransactionInProgress: true, + speculateTransactionError: null, + speculatedTransaction: null, + }; + case SPECULATE_TRANSACTION_SUCCESS: + return { + ...state, + speculateTransactionInProgress: false, + speculatedTransaction: payload.transaction, + }; + case SPECULATE_TRANSACTION_ERROR: + console.error(payload); // eslint-disable-line no-console + return { + ...state, + speculateTransactionInProgress: false, + speculateTransactionError: payload, + }; + case INITIATE_ORDER_REQUEST: return { ...state, initiateOrderError: null }; case INITIATE_ORDER_SUCCESS: return state; case INITIATE_ORDER_ERROR: - console.error(payload); // eslint-disable-line + console.error(payload); // eslint-disable-line no-console return { ...state, initiateOrderError: payload }; default: return state; @@ -55,6 +86,21 @@ const initiateOrderError = e => ({ payload: e, }); +export const speculateTransactionRequest = () => ({ type: SPECULATE_TRANSACTION_REQUEST }); + +export const speculateTransactionSuccess = transaction => ({ + type: SPECULATE_TRANSACTION_SUCCESS, + payload: { transaction }, +}); + +export const speculateTransactionError = e => ({ + type: SPECULATE_TRANSACTION_ERROR, + error: true, + payload: e, +}); + +/* ================ Thunks ================ */ + export const initiateOrder = params => (dispatch, getState, sdk) => { dispatch(initiateOrderRequest()); @@ -74,3 +120,43 @@ export const initiateOrder = params => throw e; }); }; + +/** + * Initiate the speculative transaction with the given booking details + * + * The API allows us to do speculative transaction initiation and + * transitions. This way we can make create a transaction and get the + * actual pricing information as if the transaction had been started, + * without affecting the actual data. + * + * We store this speculative transaction in the page store and use the + * pricing info for the booking breakdown to get a proper estimate for + * the price with the chosen information. + */ +export const speculateTransaction = (listingId, bookingStart, bookingEnd) => + (dispatch, getState, sdk) => { + dispatch(speculateTransactionRequest()); + const bodyParams = { + transition: propTypes.TX_TRANSITION_PREAUTHORIZE, + params: { + listingId, + bookingStart, + bookingEnd, + cardToken: 'CheckoutPage_speculative_card_token', + }, + }; + const queryParams = { + include: ['booking', 'provider'], + expand: true, + }; + return sdk.transactions + .initiateSpeculative(bodyParams, queryParams) + .then(response => { + const transactionId = response.data.data.id; + const entities = updatedEntities({}, response.data); + const denormalised = denormalisedEntities(entities, 'transaction', [transactionId]); + const tx = denormalised[0]; + dispatch(speculateTransactionSuccess(tx)); + }) + .catch(e => dispatch(speculateTransactionError(e))); + }; diff --git a/src/containers/CheckoutPage/CheckoutPage.js b/src/containers/CheckoutPage/CheckoutPage.js index 5c748c5a..22dd0097 100644 --- a/src/containers/CheckoutPage/CheckoutPage.js +++ b/src/containers/CheckoutPage/CheckoutPage.js @@ -3,16 +3,11 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { withRouter } from 'react-router-dom'; -import Decimal from 'decimal.js'; import classNames from 'classnames'; -import config from '../../config'; -import { types } from '../../util/sdkLoader'; import { pathByRouteName } from '../../util/routes'; import * as propTypes from '../../util/propTypes'; -import { ensureListing, ensureUser } from '../../util/data'; +import { ensureListing, ensureUser, ensureTransaction, ensureBooking } from '../../util/data'; import { withFlattenedRoutes } from '../../util/contextHelpers'; -import { nightsBetween } from '../../util/dates'; -import { unitDivisor, convertMoneyToNumber, convertUnitToSubUnit } from '../../util/currency'; import { AvatarMedium, BookingBreakdown, @@ -22,75 +17,75 @@ import { ResponsiveImage, } from '../../components'; import { StripePaymentForm } from '../../containers'; -import { initiateOrder, setInitialValues } from './CheckoutPage.duck'; +import { initiateOrder, setInitialValues, speculateTransaction } from './CheckoutPage.duck'; -import { storeData, storedData } from './CheckoutPageSessionHelpers'; +import { storeData, storedData, clearData } from './CheckoutPageSessionHelpers'; import LogoIcon from './LogoIcon'; import css from './CheckoutPage.css'; const STORAGE_KEY = 'CheckoutPage'; -// TODO: This is a temporary function to calculate the booking -// price. This should be removed when the API supports dry-runs and we -// can take the total price from the transaction itself. -const estimatedTotalPrice = (startDate, endDate, unitPrice) => { - const numericPrice = convertMoneyToNumber(unitPrice); - const nightCount = nightsBetween(startDate, endDate); - const numericTotalPrice = new Decimal(numericPrice).times(nightCount).toNumber(); - return new types.Money( - convertUnitToSubUnit(numericTotalPrice, unitDivisor(unitPrice.currency)), - unitPrice.currency - ); -}; - -const breakdown = (bookingStart, bookingEnd, unitPrice) => { - if (!bookingStart || !bookingEnd || !unitPrice) { - return null; - } - const totalPrice = estimatedTotalPrice(bookingStart, bookingEnd, unitPrice); - const nightCount = nightsBetween(bookingStart, bookingEnd); - - return ( - - ); -}; - export class CheckoutPageComponent extends Component { constructor(props) { super(props); - const { bookingDates, listing } = props; // Store received data - storeData(bookingDates, listing, STORAGE_KEY); + // storeData(props.bookingDates, props.listing, STORAGE_KEY); - this.state = { submitting: false }; + this.state = { + pageData: {}, + dataLoaded: false, + submitting: false, + }; + + this.loadInitialData = this.loadInitialData.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } + componentDidMount() { + this.loadInitialData(); + } + + loadInitialData() { + const { bookingDates, listing } = this.props; + const hasDataInProps = !!(bookingDates && listing); + const pageData = hasDataInProps ? { bookingDates, listing } : storedData(STORAGE_KEY); + + if (hasDataInProps) { + storeData(bookingDates, listing, STORAGE_KEY); + } + + const hasData = pageData && + pageData.listing && + pageData.listing.id && + pageData.bookingDates && + pageData.bookingDates.bookingStart && + pageData.bookingDates.bookingEnd; + + if (hasData) { + this.props.fetchSpeculatedTransaction( + pageData.listing.id, + pageData.bookingDates.bookingStart, + pageData.bookingDates.bookingEnd + ); + } + + this.setState({ pageData: pageData || {}, dataLoaded: true }); + } + handleSubmit(cardToken) { if (this.state.submitting) { return; } this.setState({ submitting: true }); - const { bookingDates, flattenedRoutes, history, sendOrderRequest, listing } = this.props; - // Get page data from passed-in props or from storage - const pageData = bookingDates && listing ? { bookingDates, listing } : storedData(STORAGE_KEY); - const { bookingStart, bookingEnd } = pageData.bookingDates || {}; - const requestParams = { listingId: pageData.listing.id, cardToken, bookingStart, bookingEnd }; + + const { flattenedRoutes, history, sendOrderRequest, speculatedTransaction } = this.props; + const requestParams = { + listingId: this.state.pageData.listing.id, + cardToken, + bookingStart: speculatedTransaction.booking.attributes.start, + bookingEnd: speculatedTransaction.booking.attributes.end, + }; sendOrderRequest(requestParams) .then(orderId => { @@ -98,7 +93,7 @@ export class CheckoutPageComponent extends Component { const orderDetailsPath = pathByRouteName('OrderDetailsPage', flattenedRoutes, { id: orderId.uuid, }); - window.sessionStorage.removeItem(STORAGE_KEY); + clearData(STORAGE_KEY); history.push(orderDetailsPath); }) .catch(() => { @@ -110,55 +105,65 @@ export class CheckoutPageComponent extends Component { const { authInfoError, logoutError, - bookingDates, + speculateTransactionInProgress, + speculateTransactionError, + speculatedTransaction, initiateOrderError, intl, - listing, params, currentUser, } = this.props; - // Get page data from passed-in props or from storage - const pageData = bookingDates && listing ? { bookingDates, listing } : storedData(STORAGE_KEY); - const { bookingStart, bookingEnd } = pageData.bookingDates || {}; - const currentListing = ensureListing(pageData.listing); + const isLoading = !this.state.dataLoaded || speculateTransactionInProgress; + + const { listing, bookingDates } = this.state.pageData; + const currentTransaction = ensureTransaction(speculatedTransaction); + const currentBooking = ensureBooking(currentTransaction.booking); + const currentListing = ensureListing(listing); const currentAuthor = ensureUser(currentListing.author); - const authorDisplayName = currentAuthor.attributes.profile.displayName; - const isOwnListing = currentListing.id && - currentUser && + const isOwnListing = currentUser && + currentUser.id && + currentAuthor && currentAuthor.id && - currentListing.author.id.uuid === currentUser.id.uuid; + currentAuthor.id.uuid === currentUser.id.uuid; + const listingIsOpen = currentListing.id && currentListing.attributes.open; - const hasBookingInfo = bookingStart && bookingEnd; + const hasListingAndAuthor = !!(currentListing.id && currentAuthor.id); + const hasBookingDates = !!(bookingDates && + bookingDates.bookingStart && + bookingDates.bookingEnd); + const hasRequiredData = hasListingAndAuthor && hasBookingDates; + const canShowPage = hasRequiredData && listingIsOpen && !isOwnListing; + const shouldRedirect = !isLoading && !canShowPage; // Redirect back to ListingPage if data is missing. // Redirection must happen before any data format error is thrown (e.g. wrong currency) - if (!currentListing.id || isOwnListing || !hasBookingInfo) { + if (shouldRedirect) { // eslint-disable-next-line no-console - console.error( - 'Listing, user, or dates invalid for checkout, redirecting back to listing page.', - { currentListing, isOwnListing, hasBookingInfo, bookingStart, bookingEnd } - ); + console.error('Missing or invalid data for checkout, redirecting back to listing page.', { + transaction: currentTransaction, + bookingDates, + listing, + }); return ; } - // Estimate total price. NOTE: this will change when we can do a - // dry-run to the API and get a proper breakdown of the price. - const unitPrice = currentListing.attributes.price; - - if (!unitPrice) { - throw new Error('Listing has no price'); - } - if (unitPrice.currency !== config.currency) { - throw new Error( - `Listing currency different from marketplace currency: ${unitPrice.currency}` - ); - } + const breakdown = currentTransaction.id + ? + : null; // Allow showing page when currentUser is still being downloaded, // but show payment form only when user info is loaded. - const showPaymentForm = currentUser && !isOwnListing; + const showPaymentForm = !!(currentUser && hasRequiredData); const listingTitle = currentListing.attributes.title; const title = intl.formatMessage({ id: 'CheckoutPage.title' }, { listingTitle }); @@ -167,28 +172,49 @@ export class CheckoutPageComponent extends Component { ? currentListing.images[0] : null; - const errorMessage = initiateOrderError + const initiateOrderErrorMessage = initiateOrderError ?

: null; + const speculateTransactionErrorMessage = speculateTransactionError + ?

+ +

+ : null; + + const topbar = ( +
+ + + + +
+ ); + + const pageLayoutProps = { authInfoError, logoutError, title }; + + if (isLoading) { + return ( + + {topbar} +
+ +
+
+ ); + } return ( - -
- - - - -
- + + {topbar}
@@ -222,14 +248,15 @@ export class CheckoutPageComponent extends Component {

- {breakdown(bookingStart, bookingEnd, unitPrice)} + {speculateTransactionErrorMessage} + {breakdown}

- {errorMessage} + {initiateOrderErrorMessage} {showPaymentForm ?

{listingTitle}

- +

- {breakdown(bookingStart, bookingEnd, unitPrice)} + {speculateTransactionErrorMessage} + {breakdown} @@ -278,23 +309,29 @@ export class CheckoutPageComponent extends Component { CheckoutPageComponent.defaultProps = { authInfoError: null, - bookingDates: null, initiateOrderError: null, listing: null, + bookingDates: null, + speculateTransactionError: null, + speculatedTransaction: null, logoutError: null, currentUser: null, }; -const { arrayOf, func, instanceOf, shape, string } = PropTypes; +const { arrayOf, func, instanceOf, shape, string, bool } = PropTypes; CheckoutPageComponent.propTypes = { authInfoError: instanceOf(Error), + listing: propTypes.listing, bookingDates: shape({ bookingStart: instanceOf(Date).isRequired, bookingEnd: instanceOf(Date).isRequired, }), + fetchSpeculatedTransaction: func.isRequired, + speculateTransactionInProgress: bool.isRequired, + speculateTransactionError: instanceOf(Error), + speculatedTransaction: propTypes.transaction, initiateOrderError: instanceOf(Error), - listing: propTypes.listing, logoutError: instanceOf(Error), currentUser: propTypes.currentUser, params: shape({ @@ -316,14 +353,33 @@ CheckoutPageComponent.propTypes = { }; const mapStateToProps = state => { - const { initiateOrderError, listing, bookingDates } = state.CheckoutPage; + const { + listing, + bookingDates, + speculateTransactionInProgress, + speculateTransactionError, + speculatedTransaction, + initiateOrderError, + } = state.CheckoutPage; const { currentUser } = state.user; const { authInfoError, logoutError } = state.Auth; - return { authInfoError, initiateOrderError, listing, logoutError, bookingDates, currentUser }; + return { + currentUser, + authInfoError, + logoutError, + bookingDates, + speculateTransactionInProgress, + speculateTransactionError, + speculatedTransaction, + listing, + initiateOrderError, + }; }; const mapDispatchToProps = dispatch => ({ sendOrderRequest: params => dispatch(initiateOrder(params)), + fetchSpeculatedTransaction: (listingId, bookingStart, bookingEnd) => + dispatch(speculateTransaction(listingId, bookingStart, bookingEnd)), }); const CheckoutPage = compose( diff --git a/src/containers/CheckoutPage/CheckoutPage.test.js b/src/containers/CheckoutPage/CheckoutPage.test.js index 3c25990f..0967b3a1 100644 --- a/src/containers/CheckoutPage/CheckoutPage.test.js +++ b/src/containers/CheckoutPage/CheckoutPage.test.js @@ -21,6 +21,8 @@ describe('CheckoutPage', () => { currentUser: createCurrentUser('current-user'), params: { id: 'listing1', slug: 'listing1' }, sendOrderRequest: noop, + fetchSpeculatedTransaction: noop, + speculateTransactionInProgress: false, }; const tree = renderShallow(); expect(tree).toMatchSnapshot(); @@ -43,12 +45,16 @@ describe('CheckoutPage', () => { }); describe('Reducer', () => { + const initialValues = { + initiateOrderError: null, + listing: null, + bookingDates: null, + speculateTransactionError: null, + speculateTransactionInProgress: false, + speculatedTransaction: null, + }; + it('should return the initial state', () => { - const initialValues = { - initiateOrderError: null, - listing: null, - bookingDates: null, - }; expect(checkoutPageReducer(undefined, {})).toEqual(initialValues); }); @@ -60,7 +66,8 @@ describe('CheckoutPage', () => { bookingEnd: new Date(Date.UTC(2017, 3, 16)), }; const payload = { listing, bookingDates }; - expect(checkoutPageReducer({}, { type: SET_INITAL_VALUES, payload })).toEqual(payload); + const expected = { ...initialValues, ...payload }; + expect(checkoutPageReducer({}, { type: SET_INITAL_VALUES, payload })).toEqual(expected); }); }); }); diff --git a/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap b/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap index 8af6f15b..d50191f4 100644 --- a/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap +++ b/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap @@ -18,193 +18,9 @@ exports[`CheckoutPage matches snapshot 1`] = `
-
- -
-
- -
-
-
-

- CheckoutPage.title -

-
- - - -
-
-
-

- -

- -
-
-

- -

- -
-
-
-
- -
-
- -
-
-

- listing1 title -

-

- -

-
-

- -

- -
+
`; diff --git a/src/translations/en.json b/src/translations/en.json index 5bef0c89..6f901719 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -30,10 +30,12 @@ "CheckoutPage.goToLandingPage": "Go to homepage", "CheckoutPage.hostedBy": "Hosted by {name}", "CheckoutPage.initiateOrderError": "Payment request failed. Please try again.", + "CheckoutPage.loadingData": "Loading checkout data...", "CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.", "CheckoutPage.paymentTitle": "Payment", "CheckoutPage.priceBreakdownTitle": "Booking breakdown", "CheckoutPage.title": "Book {listingTitle}", + "CheckoutPage.speculateTransactionError": "Failed to fetch breakdown information. Please try again.", "DateInput.clearDate": "Clear Date", "DateInput.closeDatePicker": "Close", "DateInput.defaultPlaceholder": "Date input",