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