Merge pull request #976 from sharetribe/enquire-to-tx

Enable requesting to book straight from enquiry
This commit is contained in:
Hannu Lyytikäinen 2018-12-20 18:10:39 +02:00 committed by GitHub
commit d2e8ea08df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 4629 additions and 1829 deletions

View file

@ -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)

View file

@ -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',

View file

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

View file

@ -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;
}

View file

@ -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

View file

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

View file

@ -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', () => {

View file

@ -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;
}
}

View file

@ -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
*

View file

@ -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)),
});

View file

@ -59,6 +59,7 @@ describe('CheckoutPage', () => {
speculateTransactionError: null,
speculateTransactionInProgress: false,
speculatedTransaction: null,
enquiredTransaction: null,
};
it('should return the initial state', () => {

View file

@ -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 {};

View file

@ -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>

View file

@ -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}

View file

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

View file

@ -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))]);
};

View file

@ -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

View file

@ -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} />);

View file

@ -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 {

View file

@ -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);

View file

@ -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',

View file

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

View file

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