import React, { Component } from 'react'; import { bool, func, instanceOf, object, shape, string } from 'prop-types'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { withRouter } from 'react-router-dom'; import classNames from 'classnames'; import routeConfiguration from '../../routeConfiguration'; import { pathByRouteName, findRouteByRouteName } from '../../util/routes'; import { propTypes } from '../../util/types'; import { ensureListing, ensureUser, ensureTransaction, ensureBooking } from '../../util/data'; import { dateFromLocalToAPI } from '../../util/dates'; import { createSlug } from '../../util/urlHelpers'; import { isTransactionInitiateAmountTooLowError, isTransactionInitiateListingNotFoundError, isTransactionInitiateMissingStripeAccountError, isTransactionInitiateBookingTimeNotAvailableError, isTransactionZeroPaymentError, transactionInitiateOrderStripeErrors, } from '../../util/errors'; import { AvatarMedium, BookingBreakdown, Logo, NamedLink, NamedRedirect, Page, ResponsiveImage, } from '../../components'; import { StripePaymentForm } from '../../forms'; import { isScrollingDisabled } from '../../ducks/UI.duck'; import { initiateOrder, setInitialValues, speculateTransaction } from './CheckoutPage.duck'; import config from '../../config'; import { storeData, storedData, clearData } from './CheckoutPageSessionHelpers'; import css from './CheckoutPage.css'; const STORAGE_KEY = 'CheckoutPage'; export class CheckoutPageComponent extends Component { constructor(props) { super(props); this.state = { pageData: {}, dataLoaded: false, submitting: false, }; this.loadInitialData = this.loadInitialData.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } componentWillMount() { if (window) { this.loadInitialData(); } } /** * Load initial data for the page * * Since the data for the checkout is not passed in the URL (there * might be lots of options in the future), we must pass in the data * some other way. Currently the ListingPage sets the initial data * for the CheckoutPage's Redux store. * * For some cases (e.g. a refresh in the CheckoutPage), the Redux * store is empty. To handle that case, we store the received data * to window.sessionStorage and read it from there if no props from * the store exist. * * This function also sets of fetching the speculative transaction * based on this initial data. */ loadInitialData() { const { bookingData, bookingDates, listing, fetchSpeculatedTransaction, history } = this.props; // Browser's back navigation should not rewrite data in session store. // Action is 'POP' on both history.back() and page refresh cases. // Action is 'PUSH' when user has directed through a link // Action is 'REPLACE' when user has directed through login/signup process const hasNavigatedThroughLink = history.action === 'PUSH' || history.action === 'REPLACE'; const hasDataInProps = !!(bookingData && bookingDates && listing) && hasNavigatedThroughLink; if (hasDataInProps) { // Store data only if data is passed through props and user has navigated through a link. storeData(bookingData, bookingDates, listing, STORAGE_KEY); } // NOTE: stored data can be empty if user has already successfully completed transaction. const pageData = hasDataInProps ? { bookingData, bookingDates, listing } : storedData(STORAGE_KEY); const hasData = pageData && pageData.listing && pageData.listing.id && pageData.bookingData && pageData.bookingDates && pageData.bookingDates.bookingStart && pageData.bookingDates.bookingEnd; if (hasData) { const listingId = pageData.listing.id; const { bookingStart, bookingEnd } = pageData.bookingDates; // Convert picked date to date that will be converted on the API as // a noon of correct year-month-date combo in UTC const bookingStartForAPI = dateFromLocalToAPI(bookingStart); const bookingEndForAPI = dateFromLocalToAPI(bookingEnd); // Fetch speculated transaction for showing price in booking breakdown // NOTE: if unit type is line-item/units, quantity needs to be added. // The way to pass it to checkout page is through pageData.bookingData fetchSpeculatedTransaction({ listingId, bookingStart: bookingStartForAPI, bookingEnd: bookingEndForAPI, }); } this.setState({ pageData: pageData || {}, dataLoaded: true }); } handleSubmit(values) { if (this.state.submitting) { return; } this.setState({ submitting: true }); const cardToken = values.token; const initialMessage = values.message; const { history, sendOrderRequest, speculatedTransaction, dispatch } = this.props; // Create order aka transaction // NOTE: if unit type is line-item/units, quantity needs to be added. // The way to pass it to checkout page is through pageData.bookingData const requestParams = { listingId: this.state.pageData.listing.id, cardToken, bookingStart: speculatedTransaction.booking.attributes.start, bookingEnd: speculatedTransaction.booking.attributes.end, }; sendOrderRequest(requestParams, initialMessage) .then(values => { const { orderId, initialMessageSuccess } = values; this.setState({ submitting: false }); const routes = routeConfiguration(); const OrderPage = findRouteByRouteName('OrderDetailsPage', routes); // Transaction is already created, but if the initial message // sending failed, we tell it to the OrderDetailsPage. dispatch( OrderPage.setInitialValues({ initialMessageFailedToTransaction: initialMessageSuccess ? null : orderId, }) ); const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, { id: orderId.uuid, }); clearData(STORAGE_KEY); history.push(orderDetailsPath); }) .catch(() => { this.setState({ submitting: false }); }); } render() { const { scrollingDisabled, speculateTransactionInProgress, speculateTransactionError, speculatedTransaction, initiateOrderError, intl, params, currentUser, } = this.props; // Since the listing data is already given from the ListingPage // and stored to handle refreshes, it might not have the possible // deleted or closed information in it. If the transaction // initiate or the speculative initiate fail due to the listing // being deleted or closec, we should dig the information from the // errors and not the listing data. const listingNotFound = isTransactionInitiateListingNotFoundError(speculateTransactionError) || isTransactionInitiateListingNotFoundError(initiateOrderError); const isLoading = !this.state.dataLoaded || speculateTransactionInProgress; const { listing, bookingDates } = this.state.pageData; const currentTransaction = ensureTransaction(speculatedTransaction, {}, null); const currentBooking = ensureBooking(currentTransaction.booking); const currentListing = ensureListing(listing); const currentAuthor = ensureUser(currentListing.author); const isOwnListing = currentUser && currentUser.id && currentAuthor && currentAuthor.id && currentAuthor.id.uuid === currentUser.id.uuid; const hasListingAndAuthor = !!(currentListing.id && currentAuthor.id); const hasBookingDates = !!( bookingDates && bookingDates.bookingStart && bookingDates.bookingEnd ); const hasRequiredData = hasListingAndAuthor && hasBookingDates; const canShowPage = hasRequiredData && !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 (shouldRedirect) { // eslint-disable-next-line no-console console.error('Missing or invalid data for checkout, redirecting back to listing page.', { transaction: currentTransaction, bookingDates, listing, }); return ; } // Show breakdown only when transaction and booking are loaded // (i.e. have an id) const breakdown = currentTransaction.id && currentBooking.id ? ( ) : null; // Allow showing page when currentUser is still being downloaded, // but show payment form only when user info is loaded. const showPaymentForm = !!( currentUser && hasRequiredData && !listingNotFound && !initiateOrderError && !speculateTransactionError ); const listingTitle = currentListing.attributes.title; const title = intl.formatMessage({ id: 'CheckoutPage.title' }, { listingTitle }); const firstImage = currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null; const listingNotFoundErrorMessage = listingNotFound ? (

) : null; const listingLink = ( ); const isAmountTooLowError = isTransactionInitiateAmountTooLowError(initiateOrderError); const isBookingTimeNotAvailableError = isTransactionInitiateBookingTimeNotAvailableError( initiateOrderError ); const stripeErrors = transactionInitiateOrderStripeErrors(initiateOrderError); let initiateOrderErrorMessage = null; if (!listingNotFound && isAmountTooLowError) { initiateOrderErrorMessage = (

); } else if (!listingNotFound && isBookingTimeNotAvailableError) { initiateOrderErrorMessage = (

); } else if (!listingNotFound && stripeErrors && stripeErrors.length > 0) { // NOTE: Error messages from Stripes are not part of translations. // By default they are in English. const stripeErrorsAsString = stripeErrors.join(', '); initiateOrderErrorMessage = (

); } else if (!listingNotFound && initiateOrderError) { initiateOrderErrorMessage = (

); } const speculateTransactionErrorMessage = speculateTransactionError ? (

) : null; let speculateErrorMessage = null; if (isTransactionInitiateMissingStripeAccountError(speculateTransactionError)) { speculateErrorMessage = (

); } else if (isTransactionInitiateBookingTimeNotAvailableError(speculateTransactionError)) { speculateErrorMessage = (

); } else if (isTransactionZeroPaymentError(speculateTransactionError)) { speculateErrorMessage = (

); } else if (speculateTransactionError) { speculateErrorMessage = (

); } const topbar = (
); const pageProps = { title, scrollingDisabled }; if (isLoading) { return ( {topbar}
); } return ( {topbar}

{title}

{speculateTransactionErrorMessage} {breakdown}
{initiateOrderErrorMessage} {listingNotFoundErrorMessage} {speculateErrorMessage} {showPaymentForm ? ( ) : null}

{listingTitle}

{speculateTransactionErrorMessage} {breakdown}
); } } CheckoutPageComponent.defaultProps = { initiateOrderError: null, listing: null, bookingData: {}, bookingDates: null, speculateTransactionError: null, speculatedTransaction: null, currentUser: null, }; CheckoutPageComponent.propTypes = { scrollingDisabled: bool.isRequired, listing: propTypes.listing, bookingData: object, bookingDates: shape({ bookingStart: instanceOf(Date).isRequired, bookingEnd: instanceOf(Date).isRequired, }), fetchSpeculatedTransaction: func.isRequired, speculateTransactionInProgress: bool.isRequired, speculateTransactionError: propTypes.error, speculatedTransaction: propTypes.transaction, initiateOrderError: propTypes.error, currentUser: propTypes.currentUser, params: shape({ id: string, slug: string, }).isRequired, sendOrderRequest: func.isRequired, // from connect dispatch: func.isRequired, // from injectIntl intl: intlShape.isRequired, // from withRouter history: shape({ push: func.isRequired, }).isRequired, }; const mapStateToProps = state => { const { listing, bookingData, bookingDates, speculateTransactionInProgress, speculateTransactionError, speculatedTransaction, initiateOrderError, } = state.CheckoutPage; const { currentUser } = state.user; return { scrollingDisabled: isScrollingDisabled(state), currentUser, bookingData, bookingDates, speculateTransactionInProgress, speculateTransactionError, speculatedTransaction, listing, initiateOrderError, }; }; const mapDispatchToProps = dispatch => ({ dispatch, sendOrderRequest: (params, initialMessage) => dispatch(initiateOrder(params, initialMessage)), fetchSpeculatedTransaction: params => dispatch(speculateTransaction(params)), }); const CheckoutPage = compose(withRouter, connect(mapStateToProps, mapDispatchToProps), injectIntl)( CheckoutPageComponent ); CheckoutPage.setInitialValues = initialValues => setInitialValues(initialValues); CheckoutPage.displayName = 'CheckoutPage'; export default CheckoutPage;