diff --git a/CHANGELOG.md b/CHANGELOG.md index f03d7a792c..7171c19d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/components/BookingPanel/BookingPanel.example.js b/src/components/BookingPanel/BookingPanel.example.js index 589027c148..5cabada42e 100644 --- a/src/components/BookingPanel/BookingPanel.example.js +++ b/src/components/BookingPanel/BookingPanel.example.js @@ -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: Booking title, 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: Booking title, subTitle: 'Hosted by Author N', authorDisplayName: 'Author Name', diff --git a/src/components/BookingPanel/BookingPanel.js b/src/components/BookingPanel/BookingPanel.js index 87ebd94a98..5a73862d16 100644 --- a/src/components/BookingPanel/BookingPanel.js +++ b/src/components/BookingPanel/BookingPanel.js @@ -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, diff --git a/src/components/TransactionPanel/TransactionPanel.css b/src/components/TransactionPanel/TransactionPanel.css index 377ac04b2a..b95b2ec88b 100644 --- a/src/components/TransactionPanel/TransactionPanel.css +++ b/src/components/TransactionPanel/TransactionPanel.css @@ -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; +} diff --git a/src/components/TransactionPanel/TransactionPanel.helpers.js b/src/components/TransactionPanel/TransactionPanel.helpers.js index b47c3fc989..3c0acc3f44 100644 --- a/src/components/TransactionPanel/TransactionPanel.helpers.js +++ b/src/components/TransactionPanel/TransactionPanel.helpers.js @@ -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 ? (

{fullAddress}

@@ -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 ? ( -
+

} }; -// 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 = ; - 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 canShowDetailCardHeadings ? ( +
+

{listingTitle}

+

{subTitle}

+ +
+ ) : 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; - return canShowButtons ?
{listingLink}
: null; + const isProviderLoaded = !!provider.id; + const isProviderBanned = isProviderLoaded && provider.attributes.banned; + const isCustomer = transactionRole === 'customer'; + const canShowBookingPanel = isCustomer && txIsEnquired(transaction) && !isProviderBanned; + + return canShowBookingPanel ? ( + 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 diff --git a/src/components/TransactionPanel/TransactionPanel.js b/src/components/TransactionPanel/TransactionPanel.js index 89fefc72c1..79d308dd62 100644 --- a/src/components/TransactionPanel/TransactionPanel.js +++ b/src/components/TransactionPanel/TransactionPanel.js @@ -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 = ( - - ); - } else if (canShowBookButton) { - actionButtons = ( - - ); - } + + const saleButtons = ( + + ); const sendMessagePlaceholder = intl.formatMessage( { id: 'TransactionPanel.sendMessagePlaceholder' }, @@ -284,8 +289,8 @@ export class TransactionPanelComponent extends Component { onBlur={this.onSendMessageFormBlur} onSubmit={this.onMessageSubmit} /> - {canShowActionButtons ? ( -
{actionButtons}
+ {canShowSaleButtons ? ( +
{saleButtons}
) : null}
@@ -306,33 +311,35 @@ export class TransactionPanelComponent extends Component {
) : null} - {isCustomer ? ( -
-

{listingTitle}

-

- -

- -
- ) : ( -
- -
- )} - - {canShowActionButtons ? ( -
{actionButtons}
+ + + + + + {canShowSaleButtons ? ( +
{saleButtons}
) : null} @@ -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, diff --git a/src/components/TransactionPanel/TransactionPanel.test.js b/src/components/TransactionPanel/TransactionPanel.test.js index 1b4faa091a..b3443d0a69 100644 --- a/src/components/TransactionPanel/TransactionPanel.test.js +++ b/src/components/TransactionPanel/TransactionPanel.test.js @@ -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', () => { diff --git a/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap b/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap index 57c63ef3a2..cac213d58c 100644 --- a/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap +++ b/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap @@ -849,160 +849,33 @@ exports[`TransactionPanel - Order accepted matches snapshot 1`] = ` /> -
- -
- - - - - - -`; - -exports[`TransactionPanel - Order autodeclined matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
- + +
+
+
+ + +`; + +exports[`TransactionPanel - Order autodeclined matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+
+
+ +
+
+ + + +
+
+
+ +
+`; + +exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+
+
+ +
+
+ + + +
+
+
+ +
+`; + +exports[`TransactionPanel - Order declined matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+
+
+ +
+
+ + + +
+
+
+ +
+`; + +exports[`TransactionPanel - Order delivered matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+ +
+
-
- + -
+ } + /> `; -exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` +exports[`TransactionPanel - Order enquired matches snapshot 1`] = `
@@ -2367,7 +6670,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/accept", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -2448,7 +6751,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-canceled", + "uuid": "order-enquired", }, "listing": Object { "attributes": Object { @@ -2498,7 +6801,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/accept", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -2579,7 +6882,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-canceled", + "uuid": "order-enquired", }, "listing": Object { "attributes": Object { @@ -2651,7 +6954,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/accept", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -2732,7 +7035,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-canceled", + "uuid": "order-enquired", }, "listing": Object { "attributes": Object { @@ -2779,7 +7082,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/accept", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -2860,7 +7163,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-canceled", + "uuid": "order-enquired", }, "listing": Object { "attributes": Object { @@ -2907,7 +7210,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/accept", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -2988,7 +7291,7 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-canceled", + "uuid": "order-enquired", }, "listing": Object { "attributes": Object { @@ -3109,197 +7412,70 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` onOpenReviewModal={[Function]} onShowMoreMessages={[Function]} rootClassName="" - totalMessagePages={1} - /> - -
-
-
-
-
- -
-
-
- + +
+
+
+
+
+ + /> +
-
-
-
- -
-`; - -exports[`TransactionPanel - Order declined matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
- + - -
-
- -
+ } + /> +
+
+
+ +
+`; + +exports[`TransactionPanel - Order preauthorized matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+
+ - -
-
-
-
-
- -
-
+ "provider": Object { + "attributes": Object { + "banned": false, + "profile": Object { + "abbreviatedName": "TT", + "displayName": "provider display name", + }, + }, + "id": UUID { + "uuid": "provider", + }, + "type": "user", + }, + "reviews": Array [], + "type": "transaction", + } + } + /> +
-
-
-
- -
-`; - -exports[`TransactionPanel - Order delivered matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
- + /> +
-
-
- + +
+
+
+
+
+ + /> +
-
- - -
-
-
-
-
- -
-
-
- -
+ } + /> `; -exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` +exports[`TransactionPanel - Sale accepted matches snapshot 1`] = `
@@ -5805,8 +9308,8 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/enquire", - "lastTransitionedAt": 2017-06-01T00:00:00.000Z, + "lastTransition": "transition/accept", + "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { "code": "line-item/night", @@ -5831,12 +9334,12 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "provider", ], "lineTotal": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, "reversal": false, "unitPrice": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, }, @@ -5846,7 +9349,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "currency": "USD", }, "payoutTotal": Money { - "amount": 16400, + "amount": 15500, "currency": "USD", }, "transitions": Array [ @@ -5886,7 +9389,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-enquired", + "uuid": "sale-accepted", }, "listing": Object { "attributes": Object { @@ -5936,8 +9439,8 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/enquire", - "lastTransitionedAt": 2017-06-01T00:00:00.000Z, + "lastTransition": "transition/accept", + "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { "code": "line-item/night", @@ -5962,12 +9465,12 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "provider", ], "lineTotal": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, "reversal": false, "unitPrice": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, }, @@ -5977,7 +9480,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "currency": "USD", }, "payoutTotal": Money { - "amount": 16400, + "amount": 15500, "currency": "USD", }, "transitions": Array [ @@ -6017,7 +9520,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-enquired", + "uuid": "sale-accepted", }, "listing": Object { "attributes": Object { @@ -6089,8 +9592,8 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/enquire", - "lastTransitionedAt": 2017-06-01T00:00:00.000Z, + "lastTransition": "transition/accept", + "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { "code": "line-item/night", @@ -6115,12 +9618,12 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "provider", ], "lineTotal": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, "reversal": false, "unitPrice": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, }, @@ -6130,7 +9633,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "currency": "USD", }, "payoutTotal": Money { - "amount": 16400, + "amount": 15500, "currency": "USD", }, "transitions": Array [ @@ -6170,7 +9673,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-enquired", + "uuid": "sale-accepted", }, "listing": Object { "attributes": Object { @@ -6217,8 +9720,8 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/enquire", - "lastTransitionedAt": 2017-06-01T00:00:00.000Z, + "lastTransition": "transition/accept", + "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { "code": "line-item/night", @@ -6243,12 +9746,12 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "provider", ], "lineTotal": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, "reversal": false, "unitPrice": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, }, @@ -6258,7 +9761,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "currency": "USD", }, "payoutTotal": Money { - "amount": 16400, + "amount": 15500, "currency": "USD", }, "transitions": Array [ @@ -6298,7 +9801,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-enquired", + "uuid": "sale-accepted", }, "listing": Object { "attributes": Object { @@ -6345,8 +9848,8 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/enquire", - "lastTransitionedAt": 2017-06-01T00:00:00.000Z, + "lastTransition": "transition/accept", + "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { "code": "line-item/night", @@ -6371,12 +9874,12 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "provider", ], "lineTotal": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, "reversal": false, "unitPrice": Money { - "amount": -100, + "amount": -1000, "currency": "USD", }, }, @@ -6386,7 +9889,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "currency": "USD", }, "payoutTotal": Money { - "amount": 16400, + "amount": 15500, "currency": "USD", }, "transitions": Array [ @@ -6426,7 +9929,7 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "order-enquired", + "uuid": "sale-accepted", }, "listing": Object { "attributes": Object { @@ -6470,18 +9973,18 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` Object { "attributes": Object { "banned": false, - "email": "customer@example.com", + "email": "provider@example.com", "emailVerified": true, "profile": Object { - "abbreviatedName": "customer abbreviated name", - "displayName": "customer display name", - "firstName": "customer first name", - "lastName": "customer last name", + "abbreviatedName": "provider abbreviated name", + "displayName": "provider display name", + "firstName": "provider first name", + "lastName": "provider last name", }, "stripeConnected": true, }, "id": UUID { - "uuid": "customer", + "uuid": "provider", }, "type": "currentUser", } @@ -6545,200 +10048,73 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` } oldestMessagePageFetched={1} onOpenReviewModal={[Function]} - onShowMoreMessages={[Function]} - rootClassName="" - totalMessagePages={1} - /> - -
-
-
-
-
- -
-
-
- + +
+
+
+
+
+ + /> +
-
-
-
- -
-`; - -exports[`TransactionPanel - Order preauthorized matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
- + - -
-
- -
+ } + /> +
+
+
+ +
+`; + +exports[`TransactionPanel - Sale autodeclined matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+
+ - -
-
-
-
- -
-
-
-
- -
-`; - -exports[`TransactionPanel - Sale accepted matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
+ +
+
+
+
+
+ +
+
+ - + -
-
- -
+ } + />
+
+
+ +
+`; + +exports[`TransactionPanel - Sale canceled matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+ +
+ - -
-
-
-
- -
-
-
-
- -
-`; - -exports[`TransactionPanel - Sale autodeclined matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
+ +
+
+
+
+
+ +
+
+ - + -
-
- -
+ } + />
+
+
+ +
+`; + +exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+ +
+ - -
-
-
-
- -
-
-
-
-
- -
-`; - -exports[`TransactionPanel - Sale canceled matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
- + /> +
-
-
- + +
+
+
+
+
+ + /> +
-
- + - -
-
-
-
-
- -
-
-
- -
+ } + /> `; -exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` +exports[`TransactionPanel - Sale delivered matches snapshot 1`] = `
@@ -11535,7 +14584,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/decline", + "lastTransition": "transition/complete", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -11616,7 +14665,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-declined", + "uuid": "sale-delivered", }, "listing": Object { "attributes": Object { @@ -11666,7 +14715,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/decline", + "lastTransition": "transition/complete", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -11747,7 +14796,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-declined", + "uuid": "sale-delivered", }, "listing": Object { "attributes": Object { @@ -11819,7 +14868,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/decline", + "lastTransition": "transition/complete", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -11900,7 +14949,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-declined", + "uuid": "sale-delivered", }, "listing": Object { "attributes": Object { @@ -11947,7 +14996,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/decline", + "lastTransition": "transition/complete", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -12028,7 +15077,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-declined", + "uuid": "sale-delivered", }, "listing": Object { "attributes": Object { @@ -12075,7 +15124,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/decline", + "lastTransition": "transition/complete", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -12156,7 +15205,7 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-declined", + "uuid": "sale-delivered", }, "listing": Object { "attributes": Object { @@ -12309,10 +15358,121 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` />
-
- + -
+ } + /> `; -exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` +exports[`TransactionPanel - Sale enquired matches snapshot 1`] = `
@@ -12681,7 +15903,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/complete", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -12762,7 +15984,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-delivered", + "uuid": "sale-enquired", }, "listing": Object { "attributes": Object { @@ -12812,7 +16034,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/complete", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -12893,7 +16115,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-delivered", + "uuid": "sale-enquired", }, "listing": Object { "attributes": Object { @@ -12965,7 +16187,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/complete", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -13046,7 +16268,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-delivered", + "uuid": "sale-enquired", }, "listing": Object { "attributes": Object { @@ -13093,7 +16315,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/complete", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -13174,7 +16396,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-delivered", + "uuid": "sale-enquired", }, "listing": Object { "attributes": Object { @@ -13221,7 +16443,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/complete", + "lastTransition": "transition/enquire", "lastTransitionedAt": 2017-06-10T00:00:00.000Z, "lineItems": Array [ Object { @@ -13302,7 +16524,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` "type": "user", }, "id": UUID { - "uuid": "sale-delivered", + "uuid": "sale-enquired", }, "listing": Object { "attributes": Object { @@ -13416,204 +16638,77 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` "type": "user", }, "type": "message", - }, - ] - } - oldestMessagePageFetched={1} - onOpenReviewModal={[Function]} - onShowMoreMessages={[Function]} - rootClassName="" - totalMessagePages={1} - /> - -
-
-
-
-
- -
-
-
- + +
+
+
+
+
+ + /> +
-
-
-
- -
-`; - -exports[`TransactionPanel - Sale enquired matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
- + - -
-
- -
+ } + /> +
+
+
+ +
+`; + +exports[`TransactionPanel - Sale preauthorized matches snapshot 1`] = ` +
+
+
+
+
+ +
+
+
+
+ - -
-
-
-
- -
-
-
-
-
- -
-`; - -exports[`TransactionPanel - Sale preauthorized matches snapshot 1`] = ` -
-
-
-
-
- -
-
-
- -
- + /> +
-
-
- + +
+
+
+
+
+ + /> +
-
- - -
-
-
-
-
- -
-
-
- -
+ } + /> (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 * diff --git a/src/containers/CheckoutPage/CheckoutPage.js b/src/containers/CheckoutPage/CheckoutPage.js index 4b3d8d63c5..030f949c3c 100644 --- a/src/containers/CheckoutPage/CheckoutPage.js +++ b/src/containers/CheckoutPage/CheckoutPage.js @@ -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 {
); + 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} @@ -439,12 +483,7 @@ export class CheckoutPageComponent extends Component {

{listingTitle}

-

- -

+

{detailsSubTitle}

@@ -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)), }); diff --git a/src/containers/CheckoutPage/CheckoutPage.test.js b/src/containers/CheckoutPage/CheckoutPage.test.js index a04fddb8af..5cfab905cf 100644 --- a/src/containers/CheckoutPage/CheckoutPage.test.js +++ b/src/containers/CheckoutPage/CheckoutPage.test.js @@ -59,6 +59,7 @@ describe('CheckoutPage', () => { speculateTransactionError: null, speculateTransactionInProgress: false, speculatedTransaction: null, + enquiredTransaction: null, }; it('should return the initial state', () => { diff --git a/src/containers/CheckoutPage/CheckoutPageSessionHelpers.js b/src/containers/CheckoutPage/CheckoutPageSessionHelpers.js index 7195cb39f4..b74910087d 100644 --- a/src/containers/CheckoutPage/CheckoutPageSessionHelpers.js +++ b/src/containers/CheckoutPage/CheckoutPageSessionHelpers.js @@ -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 {}; diff --git a/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap b/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap index 0e3759cee6..0002043f12 100644 --- a/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap +++ b/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap @@ -90,6 +90,7 @@ exports[`CheckoutPage matches snapshot 1`] = ` inProgress={false} onSubmit={[Function]} paymentInfo="CheckoutPage.paymentInfo" + showInitialMessageInput={true} />

@@ -134,14 +135,7 @@ exports[`CheckoutPage matches snapshot 1`] = ` listing1 title

- + 55 CheckoutPage.perNight

diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index b5d5b5cb08..304549a889 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -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} diff --git a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap index e8df2d935c..03d7922570 100644 --- a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap +++ b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap @@ -265,7 +265,6 @@ exports[`ListingPage matches snapshot 1`] = ` ({ 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]; - const canFetchListing = listing && listing.attributes && !listing.attributes.deleted; + // 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))]); }; diff --git a/src/containers/TransactionPage/TransactionPage.js b/src/containers/TransactionPage/TransactionPage.js index 0ba4a089e2..a16a1ff909 100644 --- a/src/containers/TransactionPage/TransactionPage.js +++ b/src/containers/TransactionPage/TransactionPage.js @@ -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 diff --git a/src/containers/TransactionPage/TransactionPage.test.js b/src/containers/TransactionPage/TransactionPage.test.js index e62e6d3c5a..ccec9181e8 100644 --- a/src/containers/TransactionPage/TransactionPage.test.js +++ b/src/containers/TransactionPage/TransactionPage.test.js @@ -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(); @@ -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(); diff --git a/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap b/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap index c969f6a5c1..53c4905035 100644 --- a/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap +++ b/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap @@ -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 { diff --git a/src/forms/StripePaymentForm/StripePaymentForm.js b/src/forms/StripePaymentForm/StripePaymentForm.js index d49db25c0c..e8779e0107 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.js +++ b/src/forms/StripePaymentForm/StripePaymentForm.js @@ -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 { ); + const initialMessage = showInitialMessageInput ? ( +
+

+ +

+ + +
+ ) : null; + return (

@@ -243,19 +262,7 @@ class StripePaymentForm extends Component { {this.state.error && !submitInProgress ? ( {this.state.error} ) : null} -

- -

- - + {initialMessage}

{paymentInfo}

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); diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js index 17e367267c..283bc72a31 100644 --- a/src/routeConfiguration.js +++ b/src/routeConfiguration.js @@ -193,7 +193,7 @@ const routeConfiguration = () => { auth: true, authPage: 'LoginPage', component: props => , - 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 => , - loadData: TransactionPage.loadData, + loadData: params => TransactionPage.loadData({ ...params, transactionRole: 'provider' }), }, { path: '/listings', diff --git a/src/translations/en.json b/src/translations/en.json index 2f31d57a74..a18827bad5 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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.", diff --git a/src/translations/fr.json b/src/translations/fr.json index 1b8efe511c..b97c7fffc9 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -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.",