mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
Merge pull request #976 from sharetribe/enquire-to-tx
Enable requesting to book straight from enquiry
This commit is contained in:
commit
d2e8ea08df
24 changed files with 4629 additions and 1829 deletions
|
|
@ -18,6 +18,8 @@ way to update this template, but currently, we follow a pattern:
|
|||
[#985](https://github.com/sharetribe/flex-template-web/pull/985)
|
||||
- [remove] Remove the default built-in email templates. Built-in email templates can be edited in
|
||||
Console. [#983](https://github.com/sharetribe/flex-template-web/pull/983)
|
||||
- [add] Enable booking a listing straight from an enquiry
|
||||
[#976](https://github.com/sharetribe/flex-template-web/pull/976)
|
||||
- [change] Extract SectionBooking to a distinct component from ListingPage.
|
||||
[#969](https://github.com/sharetribe/flex-template-web/pull/969)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const Default = {
|
|||
props: {
|
||||
className: css.example,
|
||||
listing: createListing('listing_1'),
|
||||
handleBookingSubmit: values => console.log('Submit:', values),
|
||||
onSubmit: values => console.log('Submit:', values),
|
||||
title: <span>Booking title</span>,
|
||||
subTitle: 'Hosted by Author N',
|
||||
authorDisplayName: 'Author Name',
|
||||
|
|
@ -22,7 +22,7 @@ export const WithClosedListing = {
|
|||
props: {
|
||||
className: css.example,
|
||||
listing: createListing('listing_1', { state: LISTING_STATE_CLOSED }),
|
||||
handleBookingSubmit: values => console.log('Submit:', values),
|
||||
onSubmit: values => console.log('Submit:', values),
|
||||
title: <span>Booking title</span>,
|
||||
subTitle: 'Hosted by Author N',
|
||||
authorDisplayName: 'Author Name',
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const BookingPanel = props => {
|
|||
listing,
|
||||
isOwnListing,
|
||||
unitType,
|
||||
handleBookingSubmit,
|
||||
onSubmit,
|
||||
title,
|
||||
subTitle,
|
||||
authorDisplayName,
|
||||
|
|
@ -116,7 +116,7 @@ const BookingPanel = props => {
|
|||
className={css.bookingForm}
|
||||
submitButtonWrapperClassName={css.bookingDatesSubmitButtonWrapper}
|
||||
unitType={unitType}
|
||||
onSubmit={handleBookingSubmit}
|
||||
onSubmit={onSubmit}
|
||||
price={price}
|
||||
isOwnListing={isOwnListing}
|
||||
timeSlots={timeSlots}
|
||||
|
|
@ -167,7 +167,7 @@ BookingPanel.propTypes = {
|
|||
listing: propTypes.listing.isRequired,
|
||||
isOwnListing: bool,
|
||||
unitType: propTypes.bookingUnitType,
|
||||
handleBookingSubmit: func.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
title: oneOfType([node, string]).isRequired,
|
||||
subTitle: oneOfType([node, string]),
|
||||
authorDisplayName: string.isRequired,
|
||||
|
|
|
|||
|
|
@ -73,9 +73,11 @@
|
|||
}
|
||||
|
||||
.avatarWrapperCustomerDesktop {
|
||||
display: none;
|
||||
composes: avatarWrapperMobile;
|
||||
|
||||
@media (--viewportLarge) {
|
||||
display: block;
|
||||
margin-left: 48px;
|
||||
}
|
||||
}
|
||||
|
|
@ -171,25 +173,27 @@
|
|||
}
|
||||
|
||||
.detailCard {
|
||||
display: none;
|
||||
|
||||
position: sticky;
|
||||
top: -200px; /* This is a hack to showcase how the component would look when the image isn't sticky */
|
||||
width: 409px;
|
||||
background-color: var(--matterColorLight);
|
||||
border: 1px solid var(--matterColorNegative);
|
||||
border-radius: 2px;
|
||||
|
||||
@media (--viewportLarge) {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: -200px; /* This is a hack to showcase how the component would look when the image isn't sticky */
|
||||
width: 409px;
|
||||
background-color: var(--matterColorLight);
|
||||
border: 1px solid var(--matterColorNegative);
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.detailCardImageWrapper {
|
||||
display: none;
|
||||
|
||||
/* Layout */
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
@media (--viewportLarge) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.detailCardHeadings {
|
||||
|
|
@ -202,10 +206,6 @@
|
|||
margin-bottom: 37px;
|
||||
}
|
||||
}
|
||||
.detailCardHeadingsProvider {
|
||||
composes: detailCardHeadings;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.detailCardTitle {
|
||||
margin-bottom: 10px;
|
||||
|
|
@ -218,13 +218,12 @@
|
|||
|
||||
.detailCardSubtitle {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media (--viewportLarge) {
|
||||
margin-top: 1px;
|
||||
margin-top: 9px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -250,6 +249,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
.breakdownContainer {
|
||||
display: none;
|
||||
|
||||
@media (--viewportLarge) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.breakdown {
|
||||
margin: 14px 24px 0 24px;
|
||||
|
||||
|
|
@ -408,3 +415,7 @@
|
|||
top: 151px;
|
||||
}
|
||||
}
|
||||
|
||||
.bookingPanel {
|
||||
margin: 16px 48px 48px 48px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { createSlug, stringify } from '../../util/urlHelpers';
|
|||
import {
|
||||
ActivityFeed,
|
||||
BookingBreakdown,
|
||||
BookingPanel,
|
||||
ExternalLink,
|
||||
NamedLink,
|
||||
PrimaryButton,
|
||||
|
|
@ -89,7 +90,6 @@ export const FeedSection = props => {
|
|||
export const AddressLinkMaybe = props => {
|
||||
const { transaction, transactionRole, currentListing } = props;
|
||||
|
||||
const isProvider = transactionRole === 'provider';
|
||||
const isCustomer = transactionRole === 'customer';
|
||||
const txIsAcceptedForCustomer = isCustomer && txHasBeenAccepted(transaction);
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ export const AddressLinkMaybe = props => {
|
|||
const fullAddress =
|
||||
typeof building === 'string' && building.length > 0 ? `${building}, ${address}` : address;
|
||||
|
||||
return (isProvider || txIsAcceptedForCustomer) && hrefToGoogleMaps ? (
|
||||
return txIsAcceptedForCustomer && hrefToGoogleMaps ? (
|
||||
<p className={css.address}>
|
||||
<ExternalLink href={hrefToGoogleMaps}>{fullAddress}</ExternalLink>
|
||||
</p>
|
||||
|
|
@ -115,17 +115,19 @@ export const AddressLinkMaybe = props => {
|
|||
|
||||
// Functional component as a helper to build BookingBreakdown
|
||||
export const BreakdownMaybe = props => {
|
||||
const { className, rootClassName, transaction, transactionRole } = props;
|
||||
const { className, rootClassName, breakdownClassName, transaction, transactionRole } = props;
|
||||
const loaded = transaction && transaction.id && transaction.booking && transaction.booking.id;
|
||||
|
||||
const classes = classNames(rootClassName || css.breakdown, className);
|
||||
const classes = classNames(rootClassName || className);
|
||||
const breakdownClasses = classNames(css.breakdown, breakdownClassName);
|
||||
|
||||
return loaded ? (
|
||||
<div>
|
||||
<div className={classes}>
|
||||
<h3 className={css.bookingBreakdownTitle}>
|
||||
<FormattedMessage id="TransactionPanel.bookingBreakdownTitle" />
|
||||
</h3>
|
||||
<BookingBreakdown
|
||||
className={classes}
|
||||
className={breakdownClasses}
|
||||
userRole={transactionRole}
|
||||
unitType={config.bookingUnitType}
|
||||
transaction={transaction}
|
||||
|
|
@ -152,16 +154,62 @@ const createListingLink = (listing, label, searchParams = {}, className = '') =>
|
|||
}
|
||||
};
|
||||
|
||||
// Functional component as a helper to build ActionButtons for
|
||||
// provider when state is preauthorized
|
||||
export const OrderActionButtonMaybe = props => {
|
||||
const { className, rootClassName, canShowButtons, listing } = props;
|
||||
// Functional component as a helper to build detail card headings
|
||||
export const DetailCardHeadingsMaybe = props => {
|
||||
const { transaction, transactionRole, listing, listingTitle, subTitle } = props;
|
||||
|
||||
const title = <FormattedMessage id="TransactionPanel.requestToBook" />;
|
||||
const listingLink = createListingLink(listing, title, { book: true }, css.requestToBookButton);
|
||||
const classes = classNames(rootClassName || css.actionButtons, className);
|
||||
const isCustomer = transactionRole === 'customer';
|
||||
const canShowDetailCardHeadings = isCustomer && !txIsEnquired(transaction);
|
||||
|
||||
return canShowButtons ? <div className={classes}>{listingLink}</div> : null;
|
||||
return canShowDetailCardHeadings ? (
|
||||
<div className={css.detailCardHeadings}>
|
||||
<h2 className={css.detailCardTitle}>{listingTitle}</h2>
|
||||
<p className={css.detailCardSubtitle}>{subTitle}</p>
|
||||
<AddressLinkMaybe
|
||||
transaction={transaction}
|
||||
transactionRole={transactionRole}
|
||||
currentListing={listing}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
// Functional component as a helper to build a BookingPanel
|
||||
export const BookingPanelMaybe = props => {
|
||||
const {
|
||||
authorDisplayName,
|
||||
transaction,
|
||||
transactionRole,
|
||||
listing,
|
||||
listingTitle,
|
||||
subTitle,
|
||||
provider,
|
||||
onSubmit,
|
||||
onManageDisableScrolling,
|
||||
timeSlots,
|
||||
fetchTimeSlotsError,
|
||||
} = props;
|
||||
|
||||
const isProviderLoaded = !!provider.id;
|
||||
const isProviderBanned = isProviderLoaded && provider.attributes.banned;
|
||||
const isCustomer = transactionRole === 'customer';
|
||||
const canShowBookingPanel = isCustomer && txIsEnquired(transaction) && !isProviderBanned;
|
||||
|
||||
return canShowBookingPanel ? (
|
||||
<BookingPanel
|
||||
className={css.bookingPanel}
|
||||
isOwnListing={false}
|
||||
listing={listing}
|
||||
handleBookingSubmit={() => console.log('submit')}
|
||||
title={listingTitle}
|
||||
subTitle={subTitle}
|
||||
authorDisplayName={authorDisplayName}
|
||||
onSubmit={onSubmit}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
timeSlots={timeSlots}
|
||||
fetchTimeSlotsError={fetchTimeSlotsError}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
// Functional component as a helper to build ActionButtons for
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { txIsEnquired, txIsRequested, propTypes } from '../../util/types';
|
||||
import { txIsRequested, LINE_ITEM_NIGHT, LINE_ITEM_DAY, propTypes } from '../../util/types';
|
||||
import { ensureListing, ensureTransaction, ensureUser } from '../../util/data';
|
||||
import { isMobileSafari } from '../../util/userAgent';
|
||||
import { formatMoney } from '../../util/currency';
|
||||
import { AvatarMedium, AvatarLarge, ResponsiveImage, ReviewModal } from '../../components';
|
||||
import { SendMessageForm } from '../../forms';
|
||||
import config from '../../config';
|
||||
|
||||
// These are internal components that make this file more readable.
|
||||
import {
|
||||
AddressLinkMaybe,
|
||||
BookingPanelMaybe,
|
||||
BreakdownMaybe,
|
||||
DetailCardHeadingsMaybe,
|
||||
FeedSection,
|
||||
OrderActionButtonMaybe,
|
||||
SaleActionButtonsMaybe,
|
||||
TransactionPageTitle,
|
||||
TransactionPageMessage,
|
||||
|
|
@ -128,6 +131,9 @@ export class TransactionPanelComponent extends Component {
|
|||
declineInProgress,
|
||||
acceptSaleError,
|
||||
declineSaleError,
|
||||
onSubmitBookingRequest,
|
||||
timeSlots,
|
||||
fetchTimeSlotsError,
|
||||
} = this.props;
|
||||
|
||||
const currentTransaction = ensureTransaction(transaction);
|
||||
|
|
@ -142,9 +148,6 @@ export class TransactionPanelComponent extends Component {
|
|||
const customerLoaded = !!currentCustomer.id;
|
||||
const isCustomerBanned = customerLoaded && currentCustomer.attributes.banned;
|
||||
const canShowSaleButtons = isProvider && txIsRequested(currentTransaction) && !isCustomerBanned;
|
||||
const isProviderLoaded = !!currentProvider.id;
|
||||
const isProviderBanned = isProviderLoaded && currentProvider.attributes.banned;
|
||||
const canShowBookButton = isCustomer && txIsEnquired(currentTransaction) && !isProviderBanned;
|
||||
|
||||
const bannedUserDisplayName = intl.formatMessage({
|
||||
id: 'TransactionPanel.bannedUserDisplayName',
|
||||
|
|
@ -164,36 +167,38 @@ export class TransactionPanelComponent extends Component {
|
|||
? deletedListingTitle
|
||||
: currentListing.attributes.title;
|
||||
|
||||
const unitType = config.bookingUnitType;
|
||||
const isNightly = unitType === LINE_ITEM_NIGHT;
|
||||
const isDaily = unitType === LINE_ITEM_DAY;
|
||||
|
||||
const unitTranslationKey = isNightly
|
||||
? 'TransactionPanel.perNight'
|
||||
: isDaily
|
||||
? 'TransactionPanel.perDay'
|
||||
: 'TransactionPanel.perUnit';
|
||||
|
||||
const price = currentListing.attributes.price;
|
||||
const formattedPrice = formatMoney(intl, price);
|
||||
const bookingSubTitle = `${formattedPrice} ${intl.formatMessage({ id: unitTranslationKey })}`;
|
||||
|
||||
const firstImage =
|
||||
currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null;
|
||||
|
||||
const actionButtonClasses = classNames(css.actionButtons);
|
||||
const canShowActionButtons = canShowBookButton || canShowSaleButtons;
|
||||
|
||||
let actionButtons = null;
|
||||
if (canShowSaleButtons) {
|
||||
actionButtons = (
|
||||
<SaleActionButtonsMaybe
|
||||
rootClassName={actionButtonClasses}
|
||||
canShowButtons={canShowSaleButtons}
|
||||
transaction={currentTransaction}
|
||||
acceptInProgress={acceptInProgress}
|
||||
declineInProgress={declineInProgress}
|
||||
acceptSaleError={acceptSaleError}
|
||||
declineSaleError={declineSaleError}
|
||||
onAcceptSale={onAcceptSale}
|
||||
onDeclineSale={onDeclineSale}
|
||||
/>
|
||||
);
|
||||
} else if (canShowBookButton) {
|
||||
actionButtons = (
|
||||
<OrderActionButtonMaybe
|
||||
rootClassName={actionButtonClasses}
|
||||
canShowButtons={canShowBookButton}
|
||||
listing={currentListing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const saleButtons = (
|
||||
<SaleActionButtonsMaybe
|
||||
rootClassName={actionButtonClasses}
|
||||
canShowButtons={canShowSaleButtons}
|
||||
transaction={currentTransaction}
|
||||
acceptInProgress={acceptInProgress}
|
||||
declineInProgress={declineInProgress}
|
||||
acceptSaleError={acceptSaleError}
|
||||
declineSaleError={declineSaleError}
|
||||
onAcceptSale={onAcceptSale}
|
||||
onDeclineSale={onDeclineSale}
|
||||
/>
|
||||
);
|
||||
|
||||
const sendMessagePlaceholder = intl.formatMessage(
|
||||
{ id: 'TransactionPanel.sendMessagePlaceholder' },
|
||||
|
|
@ -284,8 +289,8 @@ export class TransactionPanelComponent extends Component {
|
|||
onBlur={this.onSendMessageFormBlur}
|
||||
onSubmit={this.onMessageSubmit}
|
||||
/>
|
||||
{canShowActionButtons ? (
|
||||
<div className={css.mobileActionButtons}>{actionButtons}</div>
|
||||
{canShowSaleButtons ? (
|
||||
<div className={css.mobileActionButtons}>{saleButtons}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -306,33 +311,35 @@ export class TransactionPanelComponent extends Component {
|
|||
<AvatarMedium user={currentProvider} />
|
||||
</div>
|
||||
) : null}
|
||||
{isCustomer ? (
|
||||
<div className={css.detailCardHeadings}>
|
||||
<h2 className={css.detailCardTitle}>{listingTitle}</h2>
|
||||
<p className={css.detailCardSubtitle}>
|
||||
<FormattedMessage
|
||||
id="TransactionPanel.hostedBy"
|
||||
values={{ name: authorDisplayName }}
|
||||
/>
|
||||
</p>
|
||||
<AddressLinkMaybe
|
||||
transaction={currentTransaction}
|
||||
transactionRole={transactionRole}
|
||||
currentListing={currentListing}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={css.detailCardHeadingsProvider}>
|
||||
<AddressLinkMaybe
|
||||
transaction={currentTransaction}
|
||||
transactionRole={transactionRole}
|
||||
currentListing={currentListing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BreakdownMaybe transaction={currentTransaction} transactionRole={transactionRole} />
|
||||
{canShowActionButtons ? (
|
||||
<div className={css.desktopActionButtons}>{actionButtons}</div>
|
||||
|
||||
<DetailCardHeadingsMaybe
|
||||
transaction={currentTransaction}
|
||||
transactionRole={transactionRole}
|
||||
listing={currentListing}
|
||||
listingTitle={listingTitle}
|
||||
subTitle={bookingSubTitle}
|
||||
/>
|
||||
<BookingPanelMaybe
|
||||
authorDisplayName={authorDisplayName}
|
||||
transaction={currentTransaction}
|
||||
transactionRole={transactionRole}
|
||||
listing={currentListing}
|
||||
listingTitle={listingTitle}
|
||||
subTitle={bookingSubTitle}
|
||||
provider={currentProvider}
|
||||
onSubmit={onSubmitBookingRequest}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
timeSlots={timeSlots}
|
||||
fetchTimeSlotsError={fetchTimeSlotsError}
|
||||
/>
|
||||
<BreakdownMaybe
|
||||
className={css.breakdownContainer}
|
||||
transaction={currentTransaction}
|
||||
transactionRole={transactionRole}
|
||||
/>
|
||||
|
||||
{canShowSaleButtons ? (
|
||||
<div className={css.desktopActionButtons}>{saleButtons}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -363,6 +370,8 @@ TransactionPanelComponent.defaultProps = {
|
|||
initialMessageFailed: null,
|
||||
sendMessageError: null,
|
||||
sendReviewError: null,
|
||||
timeSlots: null,
|
||||
fetchTimeSlotsError: null,
|
||||
};
|
||||
|
||||
const { arrayOf, bool, func, number, string } = PropTypes;
|
||||
|
|
@ -387,6 +396,9 @@ TransactionPanelComponent.propTypes = {
|
|||
onShowMoreMessages: func.isRequired,
|
||||
onSendMessage: func.isRequired,
|
||||
onSendReview: func.isRequired,
|
||||
onSubmitBookingRequest: func.isRequired,
|
||||
timeSlots: arrayOf(propTypes.timeSlot),
|
||||
fetchTimeSlotsError: propTypes.error,
|
||||
|
||||
// Sale related props
|
||||
onAcceptSale: func.isRequired,
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ describe('TransactionPanel - Sale', () => {
|
|||
onSendMessage: noop,
|
||||
onSendReview: noop,
|
||||
onResetForm: noop,
|
||||
onSubmitBookingRequest: noop,
|
||||
intl: fakeIntl,
|
||||
};
|
||||
|
||||
|
|
@ -276,6 +277,7 @@ describe('TransactionPanel - Order', () => {
|
|||
onDeclineSale: noop,
|
||||
acceptInProgress: false,
|
||||
declineInProgress: false,
|
||||
onSubmitBookingRequest: noop,
|
||||
};
|
||||
|
||||
it('enquired matches snapshot', () => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -310,13 +310,12 @@
|
|||
|
||||
@media (--viewportLarge) {
|
||||
margin-top: 17px;
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.detailsSubtitle {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
/* Reset margins from font styles */
|
||||
margin-top: 0;
|
||||
|
|
@ -336,7 +335,7 @@
|
|||
margin: 5px 24px 25px 24px;
|
||||
|
||||
@media (--viewportLarge) {
|
||||
margin: 38px 48px 26px 48px;
|
||||
margin: 37px 48px 26px 48px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import pick from 'lodash/pick';
|
|||
import config from '../../config';
|
||||
import { denormalisedResponseEntities } from '../../util/data';
|
||||
import { storableError } from '../../util/errors';
|
||||
import { TRANSITION_REQUEST } from '../../util/types';
|
||||
import { TRANSITION_REQUEST, TRANSITION_REQUEST_AFTER_ENQUIRY } from '../../util/types';
|
||||
import * as log from '../../util/log';
|
||||
import { fetchCurrentUserHasOrdersSuccess } from '../../ducks/user.duck';
|
||||
|
||||
|
|
@ -27,6 +27,7 @@ const initialState = {
|
|||
speculateTransactionInProgress: false,
|
||||
speculateTransactionError: null,
|
||||
speculatedTransaction: null,
|
||||
enquiredTransaction: null,
|
||||
initiateOrderError: null,
|
||||
};
|
||||
|
||||
|
|
@ -145,6 +146,43 @@ export const initiateOrder = (orderParams, initialMessage) => (dispatch, getStat
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate an order after an enquiry. Transitions previously created transaction.
|
||||
*/
|
||||
export const initiateOrderAfterEnquiry = (transactionId, orderParams) => (
|
||||
dispatch,
|
||||
getState,
|
||||
sdk
|
||||
) => {
|
||||
dispatch(initiateOrderRequest());
|
||||
|
||||
const bodyParams = {
|
||||
id: transactionId,
|
||||
transition: TRANSITION_REQUEST_AFTER_ENQUIRY,
|
||||
params: orderParams,
|
||||
};
|
||||
|
||||
return sdk.transactions
|
||||
.transition(bodyParams)
|
||||
.then(response => {
|
||||
const orderId = response.data.data.id;
|
||||
dispatch(initiateOrderSuccess(orderId));
|
||||
dispatch(fetchCurrentUserHasOrdersSuccess(true));
|
||||
// set initialMessageSuccess to true to unify promise handling with initiateOrder
|
||||
return Promise.resolve({ orderId, initialMessageSuccess: true });
|
||||
})
|
||||
.catch(e => {
|
||||
dispatch(initiateOrderError(storableError(e)));
|
||||
log.error(e, 'initiate-order-failed', {
|
||||
transactionId: transactionId.uuid,
|
||||
listingId: orderParams.listingId.uuid,
|
||||
bookingStart: orderParams.bookingStart,
|
||||
bookingEnd: orderParams.bookingEnd,
|
||||
});
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate the speculative transaction with the given booking details
|
||||
*
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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 { propTypes, LINE_ITEM_NIGHT, LINE_ITEM_DAY } from '../../util/types';
|
||||
import { ensureListing, ensureUser, ensureTransaction, ensureBooking } from '../../util/data';
|
||||
import { dateFromLocalToAPI } from '../../util/dates';
|
||||
import { createSlug } from '../../util/urlHelpers';
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
isTransactionZeroPaymentError,
|
||||
transactionInitiateOrderStripeErrors,
|
||||
} from '../../util/errors';
|
||||
import { formatMoney } from '../../util/currency';
|
||||
import {
|
||||
AvatarMedium,
|
||||
BookingBreakdown,
|
||||
|
|
@ -30,7 +31,12 @@ import {
|
|||
} from '../../components';
|
||||
import { StripePaymentForm } from '../../forms';
|
||||
import { isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { initiateOrder, setInitialValues, speculateTransaction } from './CheckoutPage.duck';
|
||||
import {
|
||||
initiateOrder,
|
||||
initiateOrderAfterEnquiry,
|
||||
setInitialValues,
|
||||
speculateTransaction,
|
||||
} from './CheckoutPage.duck';
|
||||
import config from '../../config';
|
||||
|
||||
import { storeData, storedData, clearData } from './CheckoutPageSessionHelpers';
|
||||
|
|
@ -75,7 +81,14 @@ export class CheckoutPageComponent extends Component {
|
|||
* based on this initial data.
|
||||
*/
|
||||
loadInitialData() {
|
||||
const { bookingData, bookingDates, listing, fetchSpeculatedTransaction, history } = this.props;
|
||||
const {
|
||||
bookingData,
|
||||
bookingDates,
|
||||
listing,
|
||||
enquiredTransaction,
|
||||
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
|
||||
|
|
@ -85,12 +98,12 @@ export class CheckoutPageComponent extends Component {
|
|||
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);
|
||||
storeData(bookingData, bookingDates, listing, enquiredTransaction, STORAGE_KEY);
|
||||
}
|
||||
|
||||
// NOTE: stored data can be empty if user has already successfully completed transaction.
|
||||
const pageData = hasDataInProps
|
||||
? { bookingData, bookingDates, listing }
|
||||
? { bookingData, bookingDates, listing, enquiredTransaction }
|
||||
: storedData(STORAGE_KEY);
|
||||
|
||||
const hasData =
|
||||
|
|
@ -132,7 +145,13 @@ export class CheckoutPageComponent extends Component {
|
|||
|
||||
const cardToken = values.token;
|
||||
const initialMessage = values.message;
|
||||
const { history, sendOrderRequest, speculatedTransaction, dispatch } = this.props;
|
||||
const {
|
||||
history,
|
||||
sendOrderRequest,
|
||||
sendOrderRequestAfterEnquiry,
|
||||
speculatedTransaction,
|
||||
dispatch,
|
||||
} = this.props;
|
||||
|
||||
// Create order aka transaction
|
||||
// NOTE: if unit type is line-item/units, quantity needs to be added.
|
||||
|
|
@ -144,7 +163,15 @@ export class CheckoutPageComponent extends Component {
|
|||
bookingEnd: speculatedTransaction.booking.attributes.end,
|
||||
};
|
||||
|
||||
sendOrderRequest(requestParams, initialMessage)
|
||||
const enquiredTransaction = this.state.pageData.enquiredTransaction;
|
||||
|
||||
// if an enquired transaction is available, use that as basis
|
||||
// otherwise initiate a new transaction
|
||||
const initiateRequest = enquiredTransaction
|
||||
? sendOrderRequestAfterEnquiry(enquiredTransaction.id, requestParams)
|
||||
: sendOrderRequest(requestParams, initialMessage);
|
||||
|
||||
initiateRequest
|
||||
.then(values => {
|
||||
const { orderId, initialMessageSuccess } = values;
|
||||
this.setState({ submitting: false });
|
||||
|
|
@ -193,7 +220,7 @@ export class CheckoutPageComponent extends Component {
|
|||
|
||||
const isLoading = !this.state.dataLoaded || speculateTransactionInProgress;
|
||||
|
||||
const { listing, bookingDates } = this.state.pageData;
|
||||
const { listing, bookingDates, enquiredTransaction } = this.state.pageData;
|
||||
const currentTransaction = ensureTransaction(speculatedTransaction, {}, null);
|
||||
const currentBooking = ensureBooking(currentTransaction.booking);
|
||||
const currentListing = ensureListing(listing);
|
||||
|
|
@ -361,6 +388,22 @@ export class CheckoutPageComponent extends Component {
|
|||
</div>
|
||||
);
|
||||
|
||||
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 = !enquiredTransaction;
|
||||
|
||||
const pageProps = { title, scrollingDisabled };
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -420,6 +463,7 @@ export class CheckoutPageComponent extends Component {
|
|||
formId="CheckoutPagePaymentForm"
|
||||
paymentInfo={intl.formatMessage({ id: 'CheckoutPage.paymentInfo' })}
|
||||
authorDisplayName={currentAuthor.attributes.profile.displayName}
|
||||
showInitialMessageInput={showInitialMessageInput}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
@ -439,12 +483,7 @@ export class CheckoutPageComponent extends Component {
|
|||
</div>
|
||||
<div className={css.detailsHeadings}>
|
||||
<h2 className={css.detailsTitle}>{listingTitle}</h2>
|
||||
<p className={css.detailsSubtitle}>
|
||||
<FormattedMessage
|
||||
id="CheckoutPage.hostedBy"
|
||||
values={{ name: currentAuthor.attributes.profile.displayName }}
|
||||
/>
|
||||
</p>
|
||||
<p className={css.detailsSubtitle}>{detailsSubTitle}</p>
|
||||
</div>
|
||||
<h3 className={css.bookingBreakdownTitle}>
|
||||
<FormattedMessage id="CheckoutPage.priceBreakdownTitle" />
|
||||
|
|
@ -465,6 +504,7 @@ CheckoutPageComponent.defaultProps = {
|
|||
bookingDates: null,
|
||||
speculateTransactionError: null,
|
||||
speculatedTransaction: null,
|
||||
enquiredTransaction: null,
|
||||
currentUser: null,
|
||||
};
|
||||
|
||||
|
|
@ -480,6 +520,7 @@ CheckoutPageComponent.propTypes = {
|
|||
speculateTransactionInProgress: bool.isRequired,
|
||||
speculateTransactionError: propTypes.error,
|
||||
speculatedTransaction: propTypes.transaction,
|
||||
enquiredTransaction: propTypes.transaction,
|
||||
initiateOrderError: propTypes.error,
|
||||
currentUser: propTypes.currentUser,
|
||||
params: shape({
|
||||
|
|
@ -508,6 +549,7 @@ const mapStateToProps = state => {
|
|||
speculateTransactionInProgress,
|
||||
speculateTransactionError,
|
||||
speculatedTransaction,
|
||||
enquiredTransaction,
|
||||
initiateOrderError,
|
||||
} = state.CheckoutPage;
|
||||
const { currentUser } = state.user;
|
||||
|
|
@ -519,6 +561,7 @@ const mapStateToProps = state => {
|
|||
speculateTransactionInProgress,
|
||||
speculateTransactionError,
|
||||
speculatedTransaction,
|
||||
enquiredTransaction,
|
||||
listing,
|
||||
initiateOrderError,
|
||||
};
|
||||
|
|
@ -527,6 +570,8 @@ const mapStateToProps = state => {
|
|||
const mapDispatchToProps = dispatch => ({
|
||||
dispatch,
|
||||
sendOrderRequest: (params, initialMessage) => dispatch(initiateOrder(params, initialMessage)),
|
||||
sendOrderRequestAfterEnquiry: (transactionId, params) =>
|
||||
dispatch(initiateOrderAfterEnquiry(transactionId, params)),
|
||||
fetchSpeculatedTransaction: params => dispatch(speculateTransaction(params)),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ describe('CheckoutPage', () => {
|
|||
speculateTransactionError: null,
|
||||
speculateTransactionInProgress: false,
|
||||
speculatedTransaction: null,
|
||||
enquiredTransaction: null,
|
||||
};
|
||||
|
||||
it('should return the initial state', () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import moment from 'moment';
|
||||
import reduce from 'lodash/reduce';
|
||||
import { types as sdkTypes } from '../../util/sdkLoader';
|
||||
import { TRANSITION_ENQUIRE } from '../../util/types';
|
||||
|
||||
const { UUID, Money } = sdkTypes;
|
||||
|
||||
|
|
@ -46,8 +47,20 @@ export const isValidListing = listing => {
|
|||
return validateProperties(listing, props);
|
||||
};
|
||||
|
||||
// Validate content of an enquired transaction received from SessionStore.
|
||||
// An id is required and the last transition needs to be the enquire transition.
|
||||
export const isValidEnquiredTransaction = transaction => {
|
||||
const props = {
|
||||
id: id => id instanceof UUID,
|
||||
attributes: v => {
|
||||
return typeof v === 'object' && v.lastTransition === TRANSITION_ENQUIRE;
|
||||
},
|
||||
};
|
||||
return validateProperties(transaction, props);
|
||||
};
|
||||
|
||||
// Stores given bookingDates and listing to sessionStorage
|
||||
export const storeData = (bookingData, bookingDates, listing, storageKey) => {
|
||||
export const storeData = (bookingData, bookingDates, listing, enquiredTransaction, storageKey) => {
|
||||
if (window && window.sessionStorage && listing && bookingDates && bookingData) {
|
||||
// TODO: How should we deal with Dates when data is serialized?
|
||||
// Hard coded serializable date objects atm.
|
||||
|
|
@ -59,6 +72,7 @@ export const storeData = (bookingData, bookingDates, listing, storageKey) => {
|
|||
bookingEnd: { date: bookingDates.bookingEnd, _serializedType: 'SerializableDate' },
|
||||
},
|
||||
listing,
|
||||
enquiredTransaction,
|
||||
storedAt: { date: new Date(), _serializedType: 'SerializableDate' },
|
||||
};
|
||||
/* eslint-enable no-underscore-dangle */
|
||||
|
|
@ -83,7 +97,7 @@ export const storedData = storageKey => {
|
|||
return sdkTypes.reviver(k, v);
|
||||
};
|
||||
|
||||
const { bookingData, bookingDates, listing, storedAt } = checkoutPageData
|
||||
const { bookingData, bookingDates, listing, enquiredTransaction, storedAt } = checkoutPageData
|
||||
? JSON.parse(checkoutPageData, reviver)
|
||||
: {};
|
||||
|
||||
|
|
@ -92,8 +106,19 @@ export const storedData = storageKey => {
|
|||
? moment(storedAt).isAfter(moment().subtract(1, 'days'))
|
||||
: false;
|
||||
|
||||
if (isFreshlySaved && isValidBookingDates(bookingDates) && isValidListing(listing)) {
|
||||
return { bookingData, bookingDates, listing };
|
||||
// resolve enquired transaction as valid if it is missing
|
||||
const isEnquiredTransactionValid = !!enquiredTransaction
|
||||
? isValidEnquiredTransaction(enquiredTransaction)
|
||||
: true;
|
||||
|
||||
const isStoredDataValid =
|
||||
isFreshlySaved &&
|
||||
isValidBookingDates(bookingDates) &&
|
||||
isValidListing(listing) &&
|
||||
isEnquiredTransactionValid;
|
||||
|
||||
if (isStoredDataValid) {
|
||||
return { bookingData, bookingDates, listing, enquiredTransaction };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ exports[`CheckoutPage matches snapshot 1`] = `
|
|||
inProgress={false}
|
||||
onSubmit={[Function]}
|
||||
paymentInfo="CheckoutPage.paymentInfo"
|
||||
showInitialMessageInput={true}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -134,14 +135,7 @@ exports[`CheckoutPage matches snapshot 1`] = `
|
|||
listing1 title
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="CheckoutPage.hostedBy"
|
||||
values={
|
||||
Object {
|
||||
"name": "author display name",
|
||||
}
|
||||
}
|
||||
/>
|
||||
55 CheckoutPage.perNight
|
||||
</p>
|
||||
</div>
|
||||
<h3>
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ export class ListingPageComponent extends Component {
|
|||
listing={currentListing}
|
||||
isOwnListing={isOwnListing}
|
||||
unitType={unitType}
|
||||
handleBookingSubmit={handleBookingSubmit}
|
||||
onSubmit={handleBookingSubmit}
|
||||
title={bookingTitle}
|
||||
subTitle={bookingSubTitle}
|
||||
authorDisplayName={authorDisplayName}
|
||||
|
|
|
|||
|
|
@ -265,7 +265,6 @@ exports[`ListingPage matches snapshot 1`] = `
|
|||
<withRouter(InjectIntl(BookingPanel))
|
||||
authorDisplayName="user-1 display name"
|
||||
fetchTimeSlotsError={null}
|
||||
handleBookingSubmit={[Function]}
|
||||
isOwnListing={false}
|
||||
listing={
|
||||
Object {
|
||||
|
|
@ -305,6 +304,7 @@ exports[`ListingPage matches snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmit={[Function]}
|
||||
subTitle="ListingPage.bookingSubTitle"
|
||||
timeSlots={null}
|
||||
title="ListingPage.bookingSubTitle"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import pick from 'lodash/pick';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import moment from 'moment';
|
||||
import config from '../../config';
|
||||
import { types as sdkTypes } from '../../util/sdkLoader';
|
||||
import { isTransactionsTransitionInvalidTransition, storableError } from '../../util/errors';
|
||||
import {
|
||||
txIsEnquired,
|
||||
TRANSITION_ACCEPT,
|
||||
TRANSITION_DECLINE,
|
||||
TRANSITION_REVIEW_1_BY_CUSTOMER,
|
||||
|
|
@ -53,6 +56,10 @@ export const SEND_REVIEW_REQUEST = 'app/TransactionPage/SEND_REVIEW_REQUEST';
|
|||
export const SEND_REVIEW_SUCCESS = 'app/TransactionPage/SEND_REVIEW_SUCCESS';
|
||||
export const SEND_REVIEW_ERROR = 'app/TransactionPage/SEND_REVIEW_ERROR';
|
||||
|
||||
export const FETCH_TIME_SLOTS_REQUEST = 'app/TransactionPage/FETCH_TIME_SLOTS_REQUEST';
|
||||
export const FETCH_TIME_SLOTS_SUCCESS = 'app/TransactionPage/FETCH_TIME_SLOTS_SUCCESS';
|
||||
export const FETCH_TIME_SLOTS_ERROR = 'app/TransactionPage/FETCH_TIME_SLOTS_ERROR';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const initialState = {
|
||||
|
|
@ -74,6 +81,8 @@ const initialState = {
|
|||
sendMessageError: null,
|
||||
sendReviewInProgress: false,
|
||||
sendReviewError: null,
|
||||
timeSlots: null,
|
||||
fetchTimeSlotsError: null,
|
||||
};
|
||||
|
||||
// Merge entity arrays using ids, so that conflicting items in newer array (b) overwrite old values (a).
|
||||
|
|
@ -153,6 +162,13 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
|
|||
case SEND_REVIEW_ERROR:
|
||||
return { ...state, sendReviewInProgress: false, sendReviewError: payload };
|
||||
|
||||
case FETCH_TIME_SLOTS_REQUEST:
|
||||
return { ...state, fetchTimeSlotsError: null };
|
||||
case FETCH_TIME_SLOTS_SUCCESS:
|
||||
return { ...state, timeSlots: payload };
|
||||
case FETCH_TIME_SLOTS_ERROR:
|
||||
return { ...state, fetchTimeSlotsError: payload };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -200,13 +216,24 @@ const sendReviewRequest = () => ({ type: SEND_REVIEW_REQUEST });
|
|||
const sendReviewSuccess = () => ({ type: SEND_REVIEW_SUCCESS });
|
||||
const sendReviewError = e => ({ type: SEND_REVIEW_ERROR, error: true, payload: e });
|
||||
|
||||
const fetchTimeSlotsRequest = () => ({ type: FETCH_TIME_SLOTS_REQUEST });
|
||||
const fetchTimeSlotsSuccess = timeSlots => ({
|
||||
type: FETCH_TIME_SLOTS_SUCCESS,
|
||||
payload: timeSlots,
|
||||
});
|
||||
const fetchTimeSlotsError = e => ({
|
||||
type: FETCH_TIME_SLOTS_ERROR,
|
||||
error: true,
|
||||
payload: e,
|
||||
});
|
||||
|
||||
// ================ Thunks ================ //
|
||||
|
||||
const listingRelationship = txResponse => {
|
||||
return txResponse.data.data.relationships.listing.data;
|
||||
};
|
||||
|
||||
export const fetchTransaction = id => (dispatch, getState, sdk) => {
|
||||
export const fetchTransaction = (id, txRole) => (dispatch, getState, sdk) => {
|
||||
dispatch(fetchTransactionRequest());
|
||||
let txResponse = null;
|
||||
|
||||
|
|
@ -234,11 +261,23 @@ export const fetchTransaction = id => (dispatch, getState, sdk) => {
|
|||
const listingId = listingRelationship(response).id;
|
||||
const entities = updatedEntities({}, response.data);
|
||||
const listingRef = { id: listingId, type: 'listing' };
|
||||
const denormalised = denormalisedEntities(entities, [listingRef]);
|
||||
const transactionRef = { id, type: 'transaction' };
|
||||
const denormalised = denormalisedEntities(entities, [listingRef, transactionRef]);
|
||||
const listing = denormalised[0];
|
||||
const transaction = denormalised[1];
|
||||
|
||||
// Fetch time slots for transactions that are in enquired state
|
||||
const canFetchTimeslots =
|
||||
txRole === 'customer' &&
|
||||
config.fetchAvailableTimeSlots &&
|
||||
transaction &&
|
||||
txIsEnquired(transaction);
|
||||
|
||||
if (canFetchTimeslots) {
|
||||
dispatch(fetchTimeSlots(listingId));
|
||||
}
|
||||
|
||||
const canFetchListing = listing && listing.attributes && !listing.attributes.deleted;
|
||||
|
||||
if (canFetchListing) {
|
||||
return sdk.listings.show({
|
||||
id: listingId,
|
||||
|
|
@ -475,12 +514,69 @@ const isNonEmpty = value => {
|
|||
return typeof value === 'object' || Array.isArray(value) ? !isEmpty(value) : !!value;
|
||||
};
|
||||
|
||||
const timeSlotsRequest = params => (dispatch, getState, sdk) => {
|
||||
return sdk.timeslots.query(params).then(response => {
|
||||
return denormalisedResponseEntities(response);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchTimeSlots = listingId => (dispatch, getState, sdk) => {
|
||||
dispatch(fetchTimeSlotsRequest);
|
||||
|
||||
// Time slots can be fetched for 90 days at a time,
|
||||
// for at most 180 days from now. If max number of bookable
|
||||
// day exceeds 90, a second request is made.
|
||||
|
||||
const maxTimeSlots = 90;
|
||||
// booking range: today + bookable days -1
|
||||
const bookingRange = config.dayCountAvailableForBooking - 1;
|
||||
const timeSlotsRange = Math.min(bookingRange, maxTimeSlots);
|
||||
|
||||
const start = moment
|
||||
.utc()
|
||||
.startOf('day')
|
||||
.toDate();
|
||||
const end = moment()
|
||||
.utc()
|
||||
.startOf('day')
|
||||
.add(timeSlotsRange, 'days')
|
||||
.toDate();
|
||||
const params = { listingId, start, end };
|
||||
|
||||
return dispatch(timeSlotsRequest(params))
|
||||
.then(timeSlots => {
|
||||
const secondRequest = bookingRange > maxTimeSlots;
|
||||
|
||||
if (secondRequest) {
|
||||
const secondRange = Math.min(maxTimeSlots, bookingRange - maxTimeSlots);
|
||||
const secondParams = {
|
||||
listingId,
|
||||
start: end,
|
||||
end: moment(end)
|
||||
.add(secondRange, 'days')
|
||||
.toDate(),
|
||||
};
|
||||
|
||||
return dispatch(timeSlotsRequest(secondParams)).then(secondBatch => {
|
||||
const combined = timeSlots.concat(secondBatch);
|
||||
dispatch(fetchTimeSlotsSuccess(combined));
|
||||
});
|
||||
} else {
|
||||
dispatch(fetchTimeSlotsSuccess(timeSlots));
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
dispatch(fetchTimeSlotsError(storableError(e)));
|
||||
});
|
||||
};
|
||||
|
||||
// loadData is a collection of async calls that need to be made
|
||||
// before page has all the info it needs to render itself
|
||||
export const loadData = params => (dispatch, getState) => {
|
||||
const txId = new UUID(params.id);
|
||||
const state = getState().TransactionPage;
|
||||
const txRef = state.transactionRef;
|
||||
const txRole = params.transactionRole;
|
||||
|
||||
// In case a transaction reference is found from a previous
|
||||
// data load -> clear the state. Otherwise keep the non-null
|
||||
|
|
@ -489,5 +585,5 @@ export const loadData = params => (dispatch, getState) => {
|
|||
dispatch(setInitialValues(initialValues));
|
||||
|
||||
// Sale / order (i.e. transaction entity in API)
|
||||
return Promise.all([dispatch(fetchTransaction(txId)), dispatch(fetchMessages(txId, 1))]);
|
||||
return Promise.all([dispatch(fetchTransaction(txId, txRole)), dispatch(fetchMessages(txId, 1))]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
|
||||
import { createResourceLocatorString, findRouteByRouteName } from '../../util/routes';
|
||||
import routeConfiguration from '../../routeConfiguration';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { ensureListing, ensureTransaction } from '../../util/data';
|
||||
import { createSlug } from '../../util/urlHelpers';
|
||||
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
|
||||
import { isScrollingDisabled, manageDisableScrolling } from '../../ducks/UI.duck';
|
||||
import {
|
||||
|
|
@ -44,6 +48,7 @@ export const TransactionPageComponent = props => {
|
|||
totalMessagePages,
|
||||
oldestMessagePageFetched,
|
||||
fetchTransactionError,
|
||||
history,
|
||||
intl,
|
||||
messages,
|
||||
onManageDisableScrolling,
|
||||
|
|
@ -64,10 +69,43 @@ export const TransactionPageComponent = props => {
|
|||
declineSaleError,
|
||||
onAcceptSale,
|
||||
onDeclineSale,
|
||||
timeSlots,
|
||||
fetchTimeSlotsError,
|
||||
useInitialValues,
|
||||
} = props;
|
||||
|
||||
const currentTransaction = ensureTransaction(transaction);
|
||||
const currentListing = ensureListing(currentTransaction.listing);
|
||||
|
||||
const handleSubmitBookingRequest = values => {
|
||||
const { bookingDates, ...bookingData } = values;
|
||||
|
||||
const initialValues = {
|
||||
listing: currentListing,
|
||||
enquiredTransaction: currentTransaction,
|
||||
bookingData,
|
||||
bookingDates: {
|
||||
bookingStart: bookingDates.startDate,
|
||||
bookingEnd: bookingDates.endDate,
|
||||
},
|
||||
};
|
||||
|
||||
const routes = routeConfiguration();
|
||||
// Customize checkout page state with current listing and selected bookingDates
|
||||
const { setInitialValues } = findRouteByRouteName('CheckoutPage', routes);
|
||||
useInitialValues(setInitialValues, initialValues);
|
||||
|
||||
// Redirect to CheckoutPage
|
||||
history.push(
|
||||
createResourceLocatorString(
|
||||
'CheckoutPage',
|
||||
routes,
|
||||
{ id: currentListing.id.uuid, slug: createSlug(currentListing.attributes.title) },
|
||||
{}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const deletedListingTitle = intl.formatMessage({
|
||||
id: 'TransactionPage.deletedListing',
|
||||
});
|
||||
|
|
@ -158,6 +196,9 @@ export const TransactionPageComponent = props => {
|
|||
declineInProgress={declineInProgress}
|
||||
acceptSaleError={acceptSaleError}
|
||||
declineSaleError={declineSaleError}
|
||||
onSubmitBookingRequest={handleSubmitBookingRequest}
|
||||
timeSlots={timeSlots}
|
||||
fetchTimeSlotsError={fetchTimeSlotsError}
|
||||
/>
|
||||
) : (
|
||||
loadingOrFailedFetching
|
||||
|
|
@ -192,6 +233,8 @@ TransactionPageComponent.defaultProps = {
|
|||
fetchMessagesError: null,
|
||||
initialMessageFailedToTransaction: null,
|
||||
sendMessageError: null,
|
||||
timeSlots: null,
|
||||
fetchTimeSlotsError: null,
|
||||
};
|
||||
|
||||
const { bool, func, oneOf, shape, string, arrayOf, number } = PropTypes;
|
||||
|
|
@ -218,6 +261,17 @@ TransactionPageComponent.propTypes = {
|
|||
sendMessageError: propTypes.error,
|
||||
onShowMoreMessages: func.isRequired,
|
||||
onSendMessage: func.isRequired,
|
||||
timeSlots: arrayOf(propTypes.timeSlot),
|
||||
fetchTimeSlotsError: propTypes.error,
|
||||
useInitialValues: func.isRequired,
|
||||
|
||||
// from withRouter
|
||||
history: shape({
|
||||
push: func.isRequired,
|
||||
}).isRequired,
|
||||
location: shape({
|
||||
search: string,
|
||||
}).isRequired,
|
||||
|
||||
// from injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
|
|
@ -241,6 +295,8 @@ const mapStateToProps = state => {
|
|||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
timeSlots,
|
||||
fetchTimeSlotsError,
|
||||
} = state.TransactionPage;
|
||||
const { currentUser } = state.user;
|
||||
|
||||
|
|
@ -266,6 +322,8 @@ const mapStateToProps = state => {
|
|||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
timeSlots,
|
||||
fetchTimeSlotsError,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -279,10 +337,12 @@ const mapDispatchToProps = dispatch => {
|
|||
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
||||
onSendReview: (role, tx, reviewRating, reviewContent) =>
|
||||
dispatch(sendReview(role, tx, reviewRating, reviewContent)),
|
||||
useInitialValues: (setInitialValues, values) => dispatch(setInitialValues(values)),
|
||||
};
|
||||
};
|
||||
|
||||
const TransactionPage = compose(
|
||||
withRouter,
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ describe('TransactionPage - Sale', () => {
|
|||
onAcceptSale: noop,
|
||||
onDeclineSale: noop,
|
||||
scrollingDisabled: false,
|
||||
useInitialValues: noop,
|
||||
transaction,
|
||||
totalMessages: 0,
|
||||
totalMessagePages: 0,
|
||||
|
|
@ -48,6 +49,15 @@ describe('TransactionPage - Sale', () => {
|
|||
onSendMessage: noop,
|
||||
onResetForm: noop,
|
||||
intl: fakeIntl,
|
||||
|
||||
location: {
|
||||
pathname: `/sale/${txId}/details`,
|
||||
search: '',
|
||||
hash: '',
|
||||
},
|
||||
history: {
|
||||
push: () => console.log('HistoryPush called'),
|
||||
},
|
||||
};
|
||||
|
||||
const tree = renderShallow(<TransactionPageComponent {...props} />);
|
||||
|
|
@ -82,6 +92,7 @@ describe('TransactionPage - Order', () => {
|
|||
fetchMessagesInProgress: false,
|
||||
sendMessageInProgress: false,
|
||||
scrollingDisabled: false,
|
||||
useInitialValues: noop,
|
||||
transaction,
|
||||
onShowMoreMessages: noop,
|
||||
onSendMessage: noop,
|
||||
|
|
@ -92,6 +103,15 @@ describe('TransactionPage - Order', () => {
|
|||
declineInProgress: false,
|
||||
onAcceptSale: noop,
|
||||
onDeclineSale: noop,
|
||||
|
||||
location: {
|
||||
pathname: `/order/${txId}/details`,
|
||||
search: '',
|
||||
hash: '',
|
||||
},
|
||||
history: {
|
||||
push: () => console.log('HistoryPush called'),
|
||||
},
|
||||
};
|
||||
|
||||
const tree = renderShallow(<TransactionPageComponent {...props} />);
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ exports[`TransactionPage - Order matches snapshot 1`] = `
|
|||
declineSaleError={null}
|
||||
fetchMessagesError={null}
|
||||
fetchMessagesInProgress={false}
|
||||
fetchTimeSlotsError={null}
|
||||
initialMessageFailed={false}
|
||||
messages={Array []}
|
||||
oldestMessagePageFetched={0}
|
||||
|
|
@ -55,8 +56,10 @@ exports[`TransactionPage - Order matches snapshot 1`] = `
|
|||
onDeclineSale={[Function]}
|
||||
onSendMessage={[Function]}
|
||||
onShowMoreMessages={[Function]}
|
||||
onSubmitBookingRequest={[Function]}
|
||||
sendMessageError={null}
|
||||
sendMessageInProgress={false}
|
||||
timeSlots={null}
|
||||
totalMessagePages={0}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -244,6 +247,7 @@ exports[`TransactionPage - Sale matches snapshot 1`] = `
|
|||
declineInProgress={false}
|
||||
declineSaleError={null}
|
||||
fetchMessagesError={null}
|
||||
fetchTimeSlotsError={null}
|
||||
initialMessageFailed={false}
|
||||
messages={Array []}
|
||||
oldestMessagePageFetched={0}
|
||||
|
|
@ -251,8 +255,10 @@ exports[`TransactionPage - Sale matches snapshot 1`] = `
|
|||
onDeclineSale={[Function]}
|
||||
onSendMessage={[Function]}
|
||||
onShowMoreMessages={[Function]}
|
||||
onSubmitBookingRequest={[Function]}
|
||||
sendMessageError={null}
|
||||
sendMessageInProgress={false}
|
||||
timeSlots={null}
|
||||
totalMessagePages={0}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ class StripePaymentForm extends Component {
|
|||
paymentInfo,
|
||||
onChange,
|
||||
authorDisplayName,
|
||||
showInitialMessageInput,
|
||||
intl,
|
||||
} = this.props;
|
||||
const submitInProgress = this.state.submitting || inProgress;
|
||||
|
|
@ -225,6 +226,24 @@ class StripePaymentForm extends Component {
|
|||
</span>
|
||||
);
|
||||
|
||||
const initialMessage = showInitialMessageInput ? (
|
||||
<div>
|
||||
<h3 className={css.messageHeading}>
|
||||
<FormattedMessage id="StripePaymentForm.messageHeading" />
|
||||
</h3>
|
||||
<label className={css.messageLabel} htmlFor={`${formId}-message`}>
|
||||
<FormattedMessage id="StripePaymentForm.messageLabel" values={{ messageOptionalText }} />
|
||||
</label>
|
||||
<ExpandingTextarea
|
||||
id={`${formId}-message`}
|
||||
className={css.message}
|
||||
placeholder={messagePlaceholder}
|
||||
value={this.state.message}
|
||||
onChange={handleMessageChange}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Form className={classes} onSubmit={this.handleSubmit}>
|
||||
<h3 className={css.paymentHeading}>
|
||||
|
|
@ -243,19 +262,7 @@ class StripePaymentForm extends Component {
|
|||
{this.state.error && !submitInProgress ? (
|
||||
<span style={{ color: 'red' }}>{this.state.error}</span>
|
||||
) : null}
|
||||
<h3 className={css.messageHeading}>
|
||||
<FormattedMessage id="StripePaymentForm.messageHeading" />
|
||||
</h3>
|
||||
<label className={css.messageLabel} htmlFor={`${formId}-message`}>
|
||||
<FormattedMessage id="StripePaymentForm.messageLabel" values={{ messageOptionalText }} />
|
||||
</label>
|
||||
<ExpandingTextarea
|
||||
id={`${formId}-message`}
|
||||
className={css.message}
|
||||
placeholder={messagePlaceholder}
|
||||
value={this.state.message}
|
||||
onChange={handleMessageChange}
|
||||
/>
|
||||
{initialMessage}
|
||||
<div className={css.submitContainer}>
|
||||
<p className={css.paymentInfo}>{paymentInfo}</p>
|
||||
<PrimaryButton
|
||||
|
|
@ -277,6 +284,7 @@ StripePaymentForm.defaultProps = {
|
|||
rootClassName: null,
|
||||
inProgress: false,
|
||||
onChange: () => null,
|
||||
showInitialMessageInput: true,
|
||||
};
|
||||
|
||||
const { bool, func, string } = PropTypes;
|
||||
|
|
@ -291,6 +299,7 @@ StripePaymentForm.propTypes = {
|
|||
onChange: func,
|
||||
paymentInfo: string.isRequired,
|
||||
authorDisplayName: string.isRequired,
|
||||
showInitialMessageInput: bool,
|
||||
};
|
||||
|
||||
export default injectIntl(StripePaymentForm);
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ const routeConfiguration = () => {
|
|||
auth: true,
|
||||
authPage: 'LoginPage',
|
||||
component: props => <TransactionPage {...props} transactionRole="customer" />,
|
||||
loadData: TransactionPage.loadData,
|
||||
loadData: params => TransactionPage.loadData({ ...params, transactionRole: 'customer' }),
|
||||
setInitialValues: TransactionPage.setInitialValues,
|
||||
},
|
||||
{
|
||||
|
|
@ -209,7 +209,7 @@ const routeConfiguration = () => {
|
|||
auth: true,
|
||||
authPage: 'LoginPage',
|
||||
component: props => <TransactionPage {...props} transactionRole="provider" />,
|
||||
loadData: TransactionPage.loadData,
|
||||
loadData: params => TransactionPage.loadData({ ...params, transactionRole: 'provider' }),
|
||||
},
|
||||
{
|
||||
path: '/listings',
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@
|
|||
"CheckoutPage.listingNotFoundError": "Unfortunately, the listing is no longer available.",
|
||||
"CheckoutPage.loadingData": "Loading checkout data…",
|
||||
"CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.",
|
||||
"CheckoutPage.perDay": "per day",
|
||||
"CheckoutPage.perNight": "per night",
|
||||
"CheckoutPage.perUnit": "per unit",
|
||||
"CheckoutPage.priceBreakdownTitle": "Booking breakdown",
|
||||
"CheckoutPage.providerStripeAccountMissingError": "The listing author has not added their payment information and the listing cannot be booked at the moment.",
|
||||
"CheckoutPage.speculateFailedMessage": "Oops, something went wrong. Please refresh the page and try again.",
|
||||
|
|
@ -774,7 +777,6 @@
|
|||
"TransactionPanel.declineSaleFailed": "Oops, declining failed. Please try again.",
|
||||
"TransactionPanel.deletedListingOrderTitle": "a listing",
|
||||
"TransactionPanel.deletedListingTitle": "Deleted listing",
|
||||
"TransactionPanel.hostedBy": "Hosted by {name}",
|
||||
"TransactionPanel.initialMessageFailed": "Whoops, failed to send message from checkout.",
|
||||
"TransactionPanel.messageDeletedListing": "However, the listing is deleted and cannot be viewed anymore.",
|
||||
"TransactionPanel.messageLoadingFailed": "Something went wrong when loading messages. Please refresh the page and try again.",
|
||||
|
|
@ -787,6 +789,9 @@
|
|||
"TransactionPanel.orderPreauthorizedInfo": "{providerName} has been notified about the booking request. Sit back and relax.",
|
||||
"TransactionPanel.orderPreauthorizedSubtitle": "You have requested to book {listingLink}.",
|
||||
"TransactionPanel.orderPreauthorizedTitle": "Great success, {customerName}!",
|
||||
"TransactionPanel.perDay": "per day",
|
||||
"TransactionPanel.perNight": "per night",
|
||||
"TransactionPanel.perUnit": "per unit",
|
||||
"TransactionPanel.requestToBook": "Request to book",
|
||||
"TransactionPanel.saleAcceptedTitle": "You accepted a request from {customerName} to book {listingLink}.",
|
||||
"TransactionPanel.saleCancelledTitle": "The booking from {customerName} for {listingLink} has been cancelled.",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@
|
|||
"CheckoutPage.listingNotFoundError": "Hélas, cette annonce n'est plus disponible.",
|
||||
"CheckoutPage.loadingData": "Chargement des données de paiement…",
|
||||
"CheckoutPage.paymentInfo": "Vous ne serez facturé que si votre demande est acceptée par l'hôte.",
|
||||
"CheckoutPage.perDay": "par jour",
|
||||
"CheckoutPage.perNight": "par nuit",
|
||||
"CheckoutPage.perUnit": "per unit",
|
||||
"CheckoutPage.priceBreakdownTitle": "Détails de la réservation",
|
||||
"CheckoutPage.providerStripeAccountMissingError": "L'auteur de l'annonce n'a pas ajouté ses coordonnées bancaires et l'annonce ne peut pas être réserver pour le moment.",
|
||||
"CheckoutPage.speculateFailedMessage": "Oups, quelque chose n'a pas fonctionné. Veuillez rafraichir la page et essayer de nouveau.",
|
||||
|
|
@ -774,7 +777,6 @@
|
|||
"TransactionPanel.declineSaleFailed": "Oups, impossible de refuser. Veuillez essayer de nouveau..",
|
||||
"TransactionPanel.deletedListingOrderTitle": "une annonce",
|
||||
"TransactionPanel.deletedListingTitle": "Annonce supprimée",
|
||||
"TransactionPanel.hostedBy": "Proposé par {name}",
|
||||
"TransactionPanel.initialMessageFailed": "Oups, impossible d'envoyer le message durant le paiement.",
|
||||
"TransactionPanel.messageDeletedListing": "Cependant l'annonce est supprimée et ne peux plus être vue.",
|
||||
"TransactionPanel.messageLoadingFailed": "Quelque chose n'a pas fonctionné lors du chargement des messages. Veuillez rafraichir la page et essayer de nouveau.",
|
||||
|
|
@ -787,6 +789,9 @@
|
|||
"TransactionPanel.orderPreauthorizedInfo": "{providerName} a reçu un message à propos de votre demande de réservation.",
|
||||
"TransactionPanel.orderPreauthorizedSubtitle": "Vous avez fait une demande de réservation {listingLink}.",
|
||||
"TransactionPanel.orderPreauthorizedTitle": "Bravo, {customerName} !",
|
||||
"TransactionPanel.perDay": "par jour",
|
||||
"TransactionPanel.perNight": "par nuit",
|
||||
"TransactionPanel.perUnit": "per unit",
|
||||
"TransactionPanel.requestToBook": "Réserver",
|
||||
"TransactionPanel.saleAcceptedTitle": "Vous avez accepté la demande de réservation de {customerName} pour {listingLink}.",
|
||||
"TransactionPanel.saleCancelledTitle": "La réservation de {customerName} pour {listingLink} a été annulée.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue