mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-25 22:37:18 +10:00
964 lines
34 KiB
JavaScript
964 lines
34 KiB
JavaScript
import React, { Component } from 'react';
|
|
import { bool, func, instanceOf, object, oneOfType, shape, string } from 'prop-types';
|
|
import { compose } from 'redux';
|
|
import { connect } from 'react-redux';
|
|
import { FormattedMessage, injectIntl, intlShape } from '../../util/reactIntl';
|
|
import { withRouter } from 'react-router-dom';
|
|
import classNames from 'classnames';
|
|
import config from '../../config';
|
|
import routeConfiguration from '../../routeConfiguration';
|
|
import { pathByRouteName, findRouteByRouteName } from '../../util/routes';
|
|
import { propTypes, LINE_ITEM_NIGHT, LINE_ITEM_DAY, DATE_TYPE_DATE } from '../../util/types';
|
|
import {
|
|
ensureListing,
|
|
ensureCurrentUser,
|
|
ensureUser,
|
|
ensureTransaction,
|
|
ensureBooking,
|
|
ensureStripeCustomer,
|
|
ensurePaymentMethodCard,
|
|
} from '../../util/data';
|
|
import { dateFromLocalToAPI, minutesBetween } from '../../util/dates';
|
|
import { createSlug } from '../../util/urlHelpers';
|
|
import {
|
|
isTransactionInitiateAmountTooLowError,
|
|
isTransactionInitiateListingNotFoundError,
|
|
isTransactionInitiateMissingStripeAccountError,
|
|
isTransactionInitiateBookingTimeNotAvailableError,
|
|
isTransactionChargeDisabledError,
|
|
isTransactionZeroPaymentError,
|
|
transactionInitiateOrderStripeErrors,
|
|
} from '../../util/errors';
|
|
import { formatMoney } from '../../util/currency';
|
|
import { TRANSITION_ENQUIRE, txIsPaymentPending, txIsPaymentExpired } from '../../util/transaction';
|
|
import {
|
|
AvatarMedium,
|
|
BookingBreakdown,
|
|
Logo,
|
|
NamedLink,
|
|
NamedRedirect,
|
|
Page,
|
|
ResponsiveImage,
|
|
} from '../../components';
|
|
import { StripePaymentForm } from '../../forms';
|
|
import { isScrollingDisabled } from '../../ducks/UI.duck';
|
|
import { handleCardPayment, retrievePaymentIntent } from '../../ducks/stripe.duck';
|
|
import { savePaymentMethod } from '../../ducks/paymentMethods.duck';
|
|
|
|
import {
|
|
initiateOrder,
|
|
setInitialValues,
|
|
speculateTransaction,
|
|
stripeCustomer,
|
|
confirmPayment,
|
|
sendMessage,
|
|
} from './CheckoutPage.duck';
|
|
import { storeData, storedData, clearData } from './CheckoutPageSessionHelpers';
|
|
import css from './CheckoutPage.css';
|
|
|
|
const STORAGE_KEY = 'CheckoutPage';
|
|
|
|
// Stripe PaymentIntent statuses, where user actions are already completed
|
|
// https://stripe.com/docs/payments/payment-intents/status
|
|
const STRIPE_PI_USER_ACTIONS_DONE_STATUSES = ['processing', 'requires_capture', 'succeeded'];
|
|
|
|
// Payment charge options
|
|
const ONETIME_PAYMENT = 'ONETIME_PAYMENT';
|
|
const PAY_AND_SAVE_FOR_LATER_USE = 'PAY_AND_SAVE_FOR_LATER_USE';
|
|
const USE_SAVED_CARD = 'USE_SAVED_CARD';
|
|
|
|
const paymentFlow = (selectedPaymentMethod, saveAfterOnetimePayment) => {
|
|
// Payment mode could be 'replaceCard', but without explicit saveAfterOnetimePayment flag,
|
|
// we'll handle it as one-time payment
|
|
return selectedPaymentMethod === 'defaultCard'
|
|
? USE_SAVED_CARD
|
|
: saveAfterOnetimePayment
|
|
? PAY_AND_SAVE_FOR_LATER_USE
|
|
: ONETIME_PAYMENT;
|
|
};
|
|
|
|
const initializeOrderPage = (initialValues, routes, dispatch) => {
|
|
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(initialValues));
|
|
};
|
|
|
|
const checkIsPaymentExpired = existingTransaction => {
|
|
return txIsPaymentExpired(existingTransaction)
|
|
? true
|
|
: txIsPaymentPending(existingTransaction)
|
|
? minutesBetween(existingTransaction.attributes.lastTransitionedAt, new Date()) >= 15
|
|
: false;
|
|
};
|
|
|
|
export class CheckoutPageComponent extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
pageData: {},
|
|
dataLoaded: false,
|
|
submitting: false,
|
|
};
|
|
this.stripe = null;
|
|
|
|
this.onStripeInitialized = this.onStripeInitialized.bind(this);
|
|
this.loadInitialData = this.loadInitialData.bind(this);
|
|
this.handlePaymentIntent = this.handlePaymentIntent.bind(this);
|
|
this.handleSubmit = this.handleSubmit.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
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,
|
|
transaction,
|
|
fetchSpeculatedTransaction,
|
|
fetchStripeCustomer,
|
|
history,
|
|
} = this.props;
|
|
|
|
// Fetch currentUser with stripeCustomer entity
|
|
// Note: since there's need for data loading in "componentWillMount" function,
|
|
// this is added here instead of loadData static function.
|
|
fetchStripeCustomer();
|
|
|
|
// 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, transaction, STORAGE_KEY);
|
|
}
|
|
|
|
// NOTE: stored data can be empty if user has already successfully completed transaction.
|
|
const pageData = hasDataInProps
|
|
? { bookingData, bookingDates, listing, transaction }
|
|
: storedData(STORAGE_KEY);
|
|
|
|
// Check if a booking is already created according to stored data.
|
|
const tx = pageData ? pageData.transaction : null;
|
|
const isBookingCreated = tx && tx.booking && tx.booking.id;
|
|
|
|
const shouldFetchSpeculatedTransaction =
|
|
pageData &&
|
|
pageData.listing &&
|
|
pageData.listing.id &&
|
|
pageData.bookingData &&
|
|
pageData.bookingDates &&
|
|
pageData.bookingDates.bookingStart &&
|
|
pageData.bookingDates.bookingEnd &&
|
|
!isBookingCreated;
|
|
|
|
if (shouldFetchSpeculatedTransaction) {
|
|
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 });
|
|
}
|
|
|
|
handlePaymentIntent(handlePaymentParams) {
|
|
const {
|
|
currentUser,
|
|
stripeCustomerFetched,
|
|
onInitiateOrder,
|
|
onHandleCardPayment,
|
|
onConfirmPayment,
|
|
onSendMessage,
|
|
onSavePaymentMethod,
|
|
} = this.props;
|
|
const {
|
|
pageData,
|
|
speculatedTransaction,
|
|
message,
|
|
paymentIntent,
|
|
selectedPaymentMethod,
|
|
saveAfterOnetimePayment,
|
|
} = handlePaymentParams;
|
|
const storedTx = ensureTransaction(pageData.transaction);
|
|
|
|
const ensuredCurrentUser = ensureCurrentUser(currentUser);
|
|
const ensuredStripeCustomer = ensureStripeCustomer(ensuredCurrentUser.stripeCustomer);
|
|
const ensuredDefaultPaymentMethod = ensurePaymentMethodCard(
|
|
ensuredStripeCustomer.defaultPaymentMethod
|
|
);
|
|
|
|
let createdPaymentIntent = null;
|
|
|
|
const hasDefaultPaymentMethod = !!(
|
|
stripeCustomerFetched &&
|
|
ensuredStripeCustomer.attributes.stripeCustomerId &&
|
|
ensuredDefaultPaymentMethod.id
|
|
);
|
|
const stripePaymentMethodId = hasDefaultPaymentMethod
|
|
? ensuredDefaultPaymentMethod.attributes.stripePaymentMethodId
|
|
: null;
|
|
|
|
const selectedPaymentFlow = paymentFlow(selectedPaymentMethod, saveAfterOnetimePayment);
|
|
|
|
// Step 1: initiate order by requesting payment from Marketplace API
|
|
const fnRequestPayment = fnParams => {
|
|
// fnParams should be { listingId, bookingStart, bookingEnd }
|
|
const hasPaymentIntents =
|
|
storedTx.attributes.protectedData && storedTx.attributes.protectedData.stripePaymentIntents;
|
|
|
|
// If paymentIntent exists, order has been initiated previously.
|
|
return hasPaymentIntents ? Promise.resolve(storedTx) : onInitiateOrder(fnParams, storedTx.id);
|
|
};
|
|
|
|
// Step 2: pay using Stripe SDK
|
|
const fnHandleCardPayment = fnParams => {
|
|
// fnParams should be returned transaction entity
|
|
|
|
const order = ensureTransaction(fnParams);
|
|
if (order.id) {
|
|
// Store order.
|
|
const { bookingData, bookingDates, listing } = pageData;
|
|
storeData(bookingData, bookingDates, listing, order, STORAGE_KEY);
|
|
this.setState({ pageData: { ...pageData, transaction: order } });
|
|
}
|
|
|
|
const hasPaymentIntents =
|
|
order.attributes.protectedData && order.attributes.protectedData.stripePaymentIntents;
|
|
|
|
if (!hasPaymentIntents) {
|
|
throw new Error(
|
|
`Missing StripePaymentIntents key in transaction's protectedData. Check that your transaction process is configured to use payment intents.`
|
|
);
|
|
}
|
|
|
|
const { stripePaymentIntentClientSecret } = hasPaymentIntents
|
|
? order.attributes.protectedData.stripePaymentIntents.default
|
|
: null;
|
|
|
|
const { stripe, card, billingDetails, paymentIntent } = handlePaymentParams;
|
|
const stripeElementMaybe = selectedPaymentFlow !== USE_SAVED_CARD ? { card } : {};
|
|
|
|
// Note: payment_method could be set here for USE_SAVED_CARD flow.
|
|
// { payment_method: stripePaymentMethodId }
|
|
// However, we have set it already on API side, when PaymentIntent was created.
|
|
const paymentParams =
|
|
selectedPaymentFlow !== USE_SAVED_CARD
|
|
? {
|
|
payment_method_data: {
|
|
billing_details: billingDetails,
|
|
},
|
|
}
|
|
: {};
|
|
|
|
const params = {
|
|
stripePaymentIntentClientSecret,
|
|
orderId: order.id,
|
|
stripe,
|
|
...stripeElementMaybe,
|
|
paymentParams,
|
|
};
|
|
|
|
// If paymentIntent status is not waiting user action,
|
|
// handleCardPayment has been called previously.
|
|
const hasPaymentIntentUserActionsDone =
|
|
paymentIntent && STRIPE_PI_USER_ACTIONS_DONE_STATUSES.includes(paymentIntent.status);
|
|
return hasPaymentIntentUserActionsDone
|
|
? Promise.resolve({ transactionId: order.id, paymentIntent })
|
|
: onHandleCardPayment(params);
|
|
};
|
|
|
|
// Step 3: complete order by confirming payment to Marketplace API
|
|
// Parameter should contain { paymentIntent, transactionId } returned in step 2
|
|
const fnConfirmPayment = fnParams => {
|
|
createdPaymentIntent = fnParams.paymentIntent;
|
|
return onConfirmPayment(fnParams);
|
|
};
|
|
|
|
// Step 4: send initial message
|
|
const fnSendMessage = fnParams => {
|
|
return onSendMessage({ ...fnParams, message });
|
|
};
|
|
|
|
// Step 5: optionally save card as defaultPaymentMethod
|
|
const fnSavePaymentMethod = fnParams => {
|
|
const pi = createdPaymentIntent || paymentIntent;
|
|
|
|
if (selectedPaymentFlow === PAY_AND_SAVE_FOR_LATER_USE) {
|
|
return onSavePaymentMethod(ensuredStripeCustomer, pi.payment_method)
|
|
.then(response => {
|
|
if (response.errors) {
|
|
return { ...fnParams, paymentMethodSaved: false };
|
|
}
|
|
return { ...fnParams, paymentMethodSaved: true };
|
|
})
|
|
.catch(e => {
|
|
// Real error cases are catched already in paymentMethods page.
|
|
return { ...fnParams, paymentMethodSaved: false };
|
|
});
|
|
} else {
|
|
return Promise.resolve({ ...fnParams, paymentMethodSaved: true });
|
|
}
|
|
};
|
|
|
|
// Here we create promise calls in sequence
|
|
// This is pretty much the same as:
|
|
// fnRequestPayment({...initialParams})
|
|
// .then(result => fnHandleCardPayment({...result}))
|
|
// .then(result => fnConfirmPayment({...result}))
|
|
const applyAsync = (acc, val) => acc.then(val);
|
|
const composeAsync = (...funcs) => x => funcs.reduce(applyAsync, Promise.resolve(x));
|
|
const handlePaymentIntentCreation = composeAsync(
|
|
fnRequestPayment,
|
|
fnHandleCardPayment,
|
|
fnConfirmPayment,
|
|
fnSendMessage,
|
|
fnSavePaymentMethod
|
|
);
|
|
|
|
// 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 tx = speculatedTransaction ? speculatedTransaction : storedTx;
|
|
|
|
// Note: optionalPaymentParams contains Stripe paymentMethod,
|
|
// but that can also be passed on Step 2
|
|
// stripe.handleCardPayment(stripe, { payment_method: stripePaymentMethodId })
|
|
const optionalPaymentParams =
|
|
selectedPaymentFlow === USE_SAVED_CARD && hasDefaultPaymentMethod
|
|
? { paymentMethod: stripePaymentMethodId }
|
|
: selectedPaymentFlow === PAY_AND_SAVE_FOR_LATER_USE
|
|
? { setupPaymentMethodForSaving: true }
|
|
: {};
|
|
|
|
const orderParams = {
|
|
listingId: pageData.listing.id,
|
|
bookingStart: tx.booking.attributes.start,
|
|
bookingEnd: tx.booking.attributes.end,
|
|
...optionalPaymentParams,
|
|
};
|
|
|
|
return handlePaymentIntentCreation(orderParams);
|
|
}
|
|
|
|
handleSubmit(values) {
|
|
if (this.state.submitting) {
|
|
return;
|
|
}
|
|
this.setState({ submitting: true });
|
|
|
|
const { history, speculatedTransaction, currentUser, paymentIntent, dispatch } = this.props;
|
|
const { card, message, paymentMethod, formValues } = values;
|
|
const {
|
|
name,
|
|
addressLine1,
|
|
addressLine2,
|
|
postal,
|
|
city,
|
|
state,
|
|
country,
|
|
saveAfterOnetimePayment,
|
|
} = formValues;
|
|
|
|
// Billing address is recommended.
|
|
// However, let's not assume that <StripePaymentAddress> data is among formValues.
|
|
// Read more about this from Stripe's docs
|
|
// https://stripe.com/docs/stripe-js/reference#stripe-handle-card-payment-no-element
|
|
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 requestPaymentParams = {
|
|
pageData: this.state.pageData,
|
|
speculatedTransaction,
|
|
stripe: this.stripe,
|
|
card,
|
|
billingDetails,
|
|
message,
|
|
paymentIntent,
|
|
selectedPaymentMethod: paymentMethod,
|
|
saveAfterOnetimePayment: !!saveAfterOnetimePayment,
|
|
};
|
|
|
|
this.handlePaymentIntent(requestPaymentParams)
|
|
.then(res => {
|
|
const { orderId, messageSuccess, paymentMethodSaved } = res;
|
|
this.setState({ submitting: false });
|
|
|
|
const routes = routeConfiguration();
|
|
const initialMessageFailedToTransaction = messageSuccess ? null : orderId;
|
|
const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, { id: orderId.uuid });
|
|
const initialValues = {
|
|
initialMessageFailedToTransaction,
|
|
savePaymentMethodFailed: !paymentMethodSaved,
|
|
};
|
|
|
|
initializeOrderPage(initialValues, routes, dispatch);
|
|
clearData(STORAGE_KEY);
|
|
history.push(orderDetailsPath);
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
this.setState({ submitting: false });
|
|
});
|
|
}
|
|
|
|
onStripeInitialized(stripe) {
|
|
this.stripe = stripe;
|
|
|
|
const { paymentIntent, onRetrievePaymentIntent } = this.props;
|
|
const tx = this.state.pageData ? this.state.pageData.transaction : null;
|
|
|
|
// We need to get up to date PI, if booking is created but payment is not expired.
|
|
const shouldFetchPaymentIntent =
|
|
this.stripe &&
|
|
!paymentIntent &&
|
|
tx &&
|
|
tx.id &&
|
|
tx.booking &&
|
|
tx.booking.id &&
|
|
txIsPaymentPending(tx) &&
|
|
!checkIsPaymentExpired(tx);
|
|
|
|
if (shouldFetchPaymentIntent) {
|
|
const { stripePaymentIntentClientSecret } =
|
|
tx.attributes.protectedData && tx.attributes.protectedData.stripePaymentIntents
|
|
? tx.attributes.protectedData.stripePaymentIntents.default
|
|
: {};
|
|
|
|
// Fetch up to date PaymentIntent from Stripe
|
|
onRetrievePaymentIntent({ stripe, stripePaymentIntentClientSecret });
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
scrollingDisabled,
|
|
speculateTransactionInProgress,
|
|
speculateTransactionError,
|
|
speculatedTransaction: speculatedTransactionMaybe,
|
|
initiateOrderError,
|
|
confirmPaymentError,
|
|
intl,
|
|
params,
|
|
currentUser,
|
|
handleCardPaymentError,
|
|
paymentIntent,
|
|
retrievePaymentIntentError,
|
|
stripeCustomerFetched,
|
|
} = 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, transaction } = this.state.pageData;
|
|
const existingTransaction = ensureTransaction(transaction);
|
|
const speculatedTransaction = ensureTransaction(speculatedTransactionMaybe, {}, null);
|
|
const currentListing = ensureListing(listing);
|
|
const currentAuthor = ensureUser(currentListing.author);
|
|
|
|
const listingTitle = currentListing.attributes.title;
|
|
const title = intl.formatMessage({ id: 'CheckoutPage.title' }, { listingTitle });
|
|
|
|
const pageProps = { title, scrollingDisabled };
|
|
const topbar = (
|
|
<div className={css.topbar}>
|
|
<NamedLink className={css.home} name="LandingPage">
|
|
<Logo
|
|
className={css.logoMobile}
|
|
title={intl.formatMessage({ id: 'CheckoutPage.goToLandingPage' })}
|
|
format="mobile"
|
|
/>
|
|
<Logo
|
|
className={css.logoDesktop}
|
|
alt={intl.formatMessage({ id: 'CheckoutPage.goToLandingPage' })}
|
|
format="desktop"
|
|
/>
|
|
</NamedLink>
|
|
</div>
|
|
);
|
|
|
|
if (isLoading) {
|
|
return <Page {...pageProps}>{topbar}</Page>;
|
|
}
|
|
|
|
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: speculatedTransaction,
|
|
bookingDates,
|
|
listing,
|
|
});
|
|
return <NamedRedirect name="ListingPage" params={params} />;
|
|
}
|
|
|
|
// Show breakdown only when speculated transaction and booking are loaded
|
|
// (i.e. have an id)
|
|
const tx = existingTransaction.booking ? existingTransaction : speculatedTransaction;
|
|
const txBooking = ensureBooking(tx.booking);
|
|
const breakdown =
|
|
tx.id && txBooking.id ? (
|
|
<BookingBreakdown
|
|
className={css.bookingBreakdown}
|
|
userRole="customer"
|
|
unitType={config.bookingUnitType}
|
|
transaction={tx}
|
|
booking={txBooking}
|
|
dateType={DATE_TYPE_DATE}
|
|
/>
|
|
) : null;
|
|
|
|
const isPaymentExpired = checkIsPaymentExpired(existingTransaction);
|
|
const hasDefaultPaymentMethod = !!(
|
|
stripeCustomerFetched &&
|
|
ensureStripeCustomer(currentUser.stripeCustomer).attributes.stripeCustomerId &&
|
|
ensurePaymentMethodCard(currentUser.stripeCustomer.defaultPaymentMethod).id
|
|
);
|
|
|
|
// 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 &&
|
|
!retrievePaymentIntentError &&
|
|
!isPaymentExpired
|
|
);
|
|
|
|
const firstImage =
|
|
currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null;
|
|
|
|
const listingLink = (
|
|
<NamedLink
|
|
name="ListingPage"
|
|
params={{ id: currentListing.id.uuid, slug: createSlug(listingTitle) }}
|
|
>
|
|
<FormattedMessage id="CheckoutPage.errorlistingLinkText" />
|
|
</NamedLink>
|
|
);
|
|
|
|
const isAmountTooLowError = isTransactionInitiateAmountTooLowError(initiateOrderError);
|
|
const isChargeDisabledError = isTransactionChargeDisabledError(initiateOrderError);
|
|
const isBookingTimeNotAvailableError = isTransactionInitiateBookingTimeNotAvailableError(
|
|
initiateOrderError
|
|
);
|
|
const stripeErrors = transactionInitiateOrderStripeErrors(initiateOrderError);
|
|
|
|
let initiateOrderErrorMessage = null;
|
|
let listingNotFoundErrorMessage = null;
|
|
|
|
if (listingNotFound) {
|
|
listingNotFoundErrorMessage = (
|
|
<p className={css.notFoundError}>
|
|
<FormattedMessage id="CheckoutPage.listingNotFoundError" />
|
|
</p>
|
|
);
|
|
} else if (isAmountTooLowError) {
|
|
initiateOrderErrorMessage = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage id="CheckoutPage.initiateOrderAmountTooLow" />
|
|
</p>
|
|
);
|
|
} else if (isBookingTimeNotAvailableError) {
|
|
initiateOrderErrorMessage = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage id="CheckoutPage.bookingTimeNotAvailableMessage" />
|
|
</p>
|
|
);
|
|
} else if (isChargeDisabledError) {
|
|
initiateOrderErrorMessage = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage id="CheckoutPage.chargeDisabledMessage" />
|
|
</p>
|
|
);
|
|
} else if (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 = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage
|
|
id="CheckoutPage.initiateOrderStripeError"
|
|
values={{ stripeErrors: stripeErrorsAsString }}
|
|
/>
|
|
</p>
|
|
);
|
|
} else if (initiateOrderError) {
|
|
// Generic initiate order error
|
|
initiateOrderErrorMessage = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage id="CheckoutPage.initiateOrderError" values={{ listingLink }} />
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const speculateTransactionErrorMessage = speculateTransactionError ? (
|
|
<p className={css.speculateError}>
|
|
<FormattedMessage id="CheckoutPage.speculateTransactionError" />
|
|
</p>
|
|
) : null;
|
|
let speculateErrorMessage = null;
|
|
|
|
if (isTransactionInitiateMissingStripeAccountError(speculateTransactionError)) {
|
|
speculateErrorMessage = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage id="CheckoutPage.providerStripeAccountMissingError" />
|
|
</p>
|
|
);
|
|
} else if (isTransactionInitiateBookingTimeNotAvailableError(speculateTransactionError)) {
|
|
speculateErrorMessage = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage id="CheckoutPage.bookingTimeNotAvailableMessage" />
|
|
</p>
|
|
);
|
|
} else if (isTransactionZeroPaymentError(speculateTransactionError)) {
|
|
speculateErrorMessage = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage id="CheckoutPage.initiateOrderAmountTooLow" />
|
|
</p>
|
|
);
|
|
} else if (speculateTransactionError) {
|
|
speculateErrorMessage = (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage id="CheckoutPage.speculateFailedMessage" />
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const unitType = config.bookingUnitType;
|
|
const isNightly = unitType === LINE_ITEM_NIGHT;
|
|
const isDaily = unitType === LINE_ITEM_DAY;
|
|
|
|
const unitTranslationKey = isNightly
|
|
? 'CheckoutPage.perNight'
|
|
: isDaily
|
|
? 'CheckoutPage.perDay'
|
|
: 'CheckoutPage.perUnit';
|
|
|
|
const price = currentListing.attributes.price;
|
|
const formattedPrice = formatMoney(intl, price);
|
|
const detailsSubTitle = `${formattedPrice} ${intl.formatMessage({ id: unitTranslationKey })}`;
|
|
|
|
const showInitialMessageInput = !(
|
|
existingTransaction && existingTransaction.attributes.lastTransition === TRANSITION_ENQUIRE
|
|
);
|
|
|
|
// Get first and last name of the current user and use it in the StripePaymentForm to autofill the name field
|
|
const userName =
|
|
currentUser && currentUser.attributes
|
|
? `${currentUser.attributes.profile.firstName} ${currentUser.attributes.profile.lastName}`
|
|
: null;
|
|
|
|
// If paymentIntent status is not waiting user action,
|
|
// handleCardPayment has been called previously.
|
|
const hasPaymentIntentUserActionsDone =
|
|
paymentIntent && STRIPE_PI_USER_ACTIONS_DONE_STATUSES.includes(paymentIntent.status);
|
|
|
|
// If your marketplace works mostly in one country you can use initial values to select country automatically
|
|
// e.g. {country: 'FI'}
|
|
|
|
const initalValuesForStripePayment = { name: userName };
|
|
|
|
return (
|
|
<Page {...pageProps}>
|
|
{topbar}
|
|
<div className={css.contentContainer}>
|
|
<div className={css.aspectWrapper}>
|
|
<ResponsiveImage
|
|
rootClassName={css.rootForImage}
|
|
alt={listingTitle}
|
|
image={firstImage}
|
|
variants={['landscape-crop', 'landscape-crop2x']}
|
|
/>
|
|
</div>
|
|
<div className={classNames(css.avatarWrapper, css.avatarMobile)}>
|
|
<AvatarMedium user={currentAuthor} disableProfileLink />
|
|
</div>
|
|
<div className={css.bookListingContainer}>
|
|
<div className={css.heading}>
|
|
<h1 className={css.title}>{title}</h1>
|
|
<div className={css.author}>
|
|
<FormattedMessage
|
|
id="CheckoutPage.hostedBy"
|
|
values={{ name: currentAuthor.attributes.profile.displayName }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={css.priceBreakdownContainer}>
|
|
{speculateTransactionErrorMessage}
|
|
{breakdown}
|
|
</div>
|
|
|
|
<section className={css.paymentContainer}>
|
|
{initiateOrderErrorMessage}
|
|
{listingNotFoundErrorMessage}
|
|
{speculateErrorMessage}
|
|
{retrievePaymentIntentError ? (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage
|
|
id="CheckoutPage.retrievingStripePaymentIntentFailed"
|
|
values={{ listingLink }}
|
|
/>
|
|
</p>
|
|
) : null}
|
|
{showPaymentForm ? (
|
|
<StripePaymentForm
|
|
className={css.paymentForm}
|
|
onSubmit={this.handleSubmit}
|
|
inProgress={this.state.submitting}
|
|
formId="CheckoutPagePaymentForm"
|
|
paymentInfo={intl.formatMessage({ id: 'CheckoutPage.paymentInfo' })}
|
|
authorDisplayName={currentAuthor.attributes.profile.displayName}
|
|
showInitialMessageInput={showInitialMessageInput}
|
|
initialValues={initalValuesForStripePayment}
|
|
initiateOrderError={initiateOrderError}
|
|
handleCardPaymentError={handleCardPaymentError}
|
|
confirmPaymentError={confirmPaymentError}
|
|
hasHandledCardPayment={hasPaymentIntentUserActionsDone}
|
|
loadingData={!stripeCustomerFetched}
|
|
defaultPaymentMethod={
|
|
hasDefaultPaymentMethod ? currentUser.stripeCustomer.defaultPaymentMethod : null
|
|
}
|
|
paymentIntent={paymentIntent}
|
|
onStripeInitialized={this.onStripeInitialized}
|
|
/>
|
|
) : null}
|
|
{isPaymentExpired ? (
|
|
<p className={css.orderError}>
|
|
<FormattedMessage
|
|
id="CheckoutPage.paymentExpiredMessage"
|
|
values={{ listingLink }}
|
|
/>
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
|
|
<div className={css.detailsContainerDesktop}>
|
|
<div className={css.detailsAspectWrapper}>
|
|
<ResponsiveImage
|
|
rootClassName={css.rootForImage}
|
|
alt={listingTitle}
|
|
image={firstImage}
|
|
variants={['landscape-crop', 'landscape-crop2x']}
|
|
/>
|
|
</div>
|
|
<div className={css.avatarWrapper}>
|
|
<AvatarMedium user={currentAuthor} disableProfileLink />
|
|
</div>
|
|
<div className={css.detailsHeadings}>
|
|
<h2 className={css.detailsTitle}>{listingTitle}</h2>
|
|
<p className={css.detailsSubtitle}>{detailsSubTitle}</p>
|
|
</div>
|
|
{speculateTransactionErrorMessage}
|
|
{breakdown}
|
|
</div>
|
|
</div>
|
|
</Page>
|
|
);
|
|
}
|
|
}
|
|
|
|
CheckoutPageComponent.defaultProps = {
|
|
initiateOrderError: null,
|
|
confirmPaymentError: null,
|
|
listing: null,
|
|
bookingData: {},
|
|
bookingDates: null,
|
|
speculateTransactionError: null,
|
|
speculatedTransaction: null,
|
|
transaction: null,
|
|
currentUser: null,
|
|
paymentIntent: null,
|
|
};
|
|
|
|
CheckoutPageComponent.propTypes = {
|
|
scrollingDisabled: bool.isRequired,
|
|
listing: propTypes.listing,
|
|
bookingData: object,
|
|
bookingDates: shape({
|
|
bookingStart: instanceOf(Date).isRequired,
|
|
bookingEnd: instanceOf(Date).isRequired,
|
|
}),
|
|
fetchStripeCustomer: func.isRequired,
|
|
stripeCustomerFetched: bool.isRequired,
|
|
fetchSpeculatedTransaction: func.isRequired,
|
|
speculateTransactionInProgress: bool.isRequired,
|
|
speculateTransactionError: propTypes.error,
|
|
speculatedTransaction: propTypes.transaction,
|
|
transaction: propTypes.transaction,
|
|
currentUser: propTypes.currentUser,
|
|
params: shape({
|
|
id: string,
|
|
slug: string,
|
|
}).isRequired,
|
|
onConfirmPayment: func.isRequired,
|
|
onInitiateOrder: func.isRequired,
|
|
onHandleCardPayment: func.isRequired,
|
|
onRetrievePaymentIntent: func.isRequired,
|
|
onSavePaymentMethod: func.isRequired,
|
|
onSendMessage: func.isRequired,
|
|
initiateOrderError: propTypes.error,
|
|
confirmPaymentError: propTypes.error,
|
|
// handleCardPaymentError comes from Stripe so that's why we can't expect it to be in a specific form
|
|
handleCardPaymentError: oneOfType([propTypes.error, object]),
|
|
paymentIntent: object,
|
|
|
|
// 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,
|
|
stripeCustomerFetched,
|
|
speculateTransactionInProgress,
|
|
speculateTransactionError,
|
|
speculatedTransaction,
|
|
transaction,
|
|
initiateOrderError,
|
|
confirmPaymentError,
|
|
} = state.CheckoutPage;
|
|
const { currentUser } = state.user;
|
|
const { handleCardPaymentError, paymentIntent, retrievePaymentIntentError } = state.stripe;
|
|
return {
|
|
scrollingDisabled: isScrollingDisabled(state),
|
|
currentUser,
|
|
stripeCustomerFetched,
|
|
bookingData,
|
|
bookingDates,
|
|
speculateTransactionInProgress,
|
|
speculateTransactionError,
|
|
speculatedTransaction,
|
|
transaction,
|
|
listing,
|
|
initiateOrderError,
|
|
handleCardPaymentError,
|
|
confirmPaymentError,
|
|
paymentIntent,
|
|
retrievePaymentIntentError,
|
|
};
|
|
};
|
|
|
|
const mapDispatchToProps = dispatch => ({
|
|
dispatch,
|
|
fetchSpeculatedTransaction: params => dispatch(speculateTransaction(params)),
|
|
fetchStripeCustomer: () => dispatch(stripeCustomer()),
|
|
onInitiateOrder: (params, transactionId) => dispatch(initiateOrder(params, transactionId)),
|
|
onRetrievePaymentIntent: params => dispatch(retrievePaymentIntent(params)),
|
|
onHandleCardPayment: params => dispatch(handleCardPayment(params)),
|
|
onConfirmPayment: params => dispatch(confirmPayment(params)),
|
|
onSendMessage: params => dispatch(sendMessage(params)),
|
|
onSavePaymentMethod: (stripeCustomer, stripePaymentMethodId) =>
|
|
dispatch(savePaymentMethod(stripeCustomer, stripePaymentMethodId)),
|
|
});
|
|
|
|
const CheckoutPage = compose(
|
|
withRouter,
|
|
connect(
|
|
mapStateToProps,
|
|
mapDispatchToProps
|
|
),
|
|
injectIntl
|
|
)(CheckoutPageComponent);
|
|
|
|
CheckoutPage.setInitialValues = initialValues => setInitialValues(initialValues);
|
|
|
|
CheckoutPage.displayName = 'CheckoutPage';
|
|
|
|
export default CheckoutPage;
|