Merge pull request #892 from sharetribe/availability

Add availability support
This commit is contained in:
Hannu Lyytikäinen 2018-08-13 15:24:03 +03:00 committed by GitHub
commit f6fbae7dd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 145 additions and 57 deletions

View file

@ -12,6 +12,9 @@ way to update this template, but currently, we follow a pattern:
---
## Upcoming version 2018-08-XX
* [add] Listing availability
[#868](https://github.com/sharetribe/flex-template-web/pull/868), [#873](https://github.com/sharetribe/flex-template-web/pull/873), [#891](https://github.com/sharetribe/flex-template-web/pull/891) & [#892](https://github.com/sharetribe/flex-template-web/pull/892)
* [change] Add support for default locations in the
LocationAutocompleteInput component. Common searches can be

View file

@ -3,7 +3,7 @@ import { isSameDay, isInclusivelyAfterDay, isInclusivelyBeforeDay } from 'react-
import { ensureTimeSlot } from '../../util/data';
import { START_DATE, END_DATE, dateFromAPIToLocalNoon } from '../../util/dates';
import { LINE_ITEM_DAY, TIME_SLOT_DAY } from '../../util/types';
import { LINE_ITEM_DAY, LINE_ITEM_NIGHT, TIME_SLOT_DAY } from '../../util/types';
import config from '../../config';
// Checks if time slot (propTypes.timeSlot) start time equals a day (moment)
@ -28,16 +28,6 @@ const timeSlotsContain = (timeSlots, date) => {
return timeSlots.findIndex(slot => timeSlotEqualsDay(slot, date)) > -1;
};
const lastBlockedBetweenExclusive = (timeSlots, startDate, endDate) => {
if (startDate.isSame(endDate, 'date')) {
return null;
}
return timeSlotsContain(timeSlots, endDate)
? lastBlockedBetweenExclusive(timeSlots, startDate, moment(endDate).subtract(1, 'days'))
: endDate;
};
/**
* Find first blocked date between two dates.
* If none is found, null is returned.
@ -89,6 +79,9 @@ export const isBlockedBetween = (timeSlots, startDate, endDate) =>
export const isStartDateSelected = (timeSlots, startDate, endDate, focusedInput) =>
timeSlots && startDate && (!endDate || focusedInput === END_DATE) && focusedInput !== START_DATE;
export const isSelectingEndDateNightly = (timeSlots, startDate, endDate, focusedInput, unitType) =>
timeSlots && !startDate && !endDate && focusedInput === END_DATE && unitType === LINE_ITEM_NIGHT;
export const apiEndDateToPickerDate = (unitType, endDate) => {
const isValid = endDate instanceof Date;
const isDaily = unitType === LINE_ITEM_DAY;
@ -118,38 +111,52 @@ export const pickerEndDateToApiDate = (unitType, endDate) => {
return endDate.toDate();
}
};
/**
* Returns an isDayBlocked function that can be passed to
* a react-dates DateRangePicker component.
*/
export const isDayBlockedFn = (timeSlots, startDate, endDate, focusedInput) => {
export const isDayBlockedFn = (timeSlots, startDate, endDate, focusedInput, unitType) => {
const endOfRange = config.dayCountAvailableForBooking - 1;
const lastBookableDate = moment().add(endOfRange, 'days');
// start date selected, end date missing
const startDateSelected = isStartDateSelected(timeSlots, startDate, endDate, focusedInput);
// find the next booking after a start date
const nextBookingStarts = startDateSelected
? firstBlockedBetween(timeSlots, startDate, moment(lastBookableDate).add(1, 'days'))
: null;
return nextBookingStarts || !timeSlots
? () => false
: day => !timeSlots.find(timeSlot => timeSlotEqualsDay(timeSlot, day));
// end date is focused but no dates are selected
const selectingEndDate = isSelectingEndDateNightly(
timeSlots,
startDate,
endDate,
focusedInput,
unitType
);
if (selectingEndDate) {
// if end date is being selected first, block the day after a booked date
// (as a booking can end on the day the following booking starts)
return day =>
!timeSlots.find(timeSlot => timeSlotEqualsDay(timeSlot, moment(day).subtract(1, 'days')));
} else if (nextBookingStarts || !timeSlots) {
// a next booking is found or time slots are not provided
// -> booking range handles blocking dates
return () => false;
} else {
// otherwise return standard timeslots check
return day => !timeSlots.find(timeSlot => timeSlotEqualsDay(timeSlot, day));
}
};
/**
* Returns an isOutsideRange function that can be passed to
* a react-dates DateRangePicker component.
*/
export const isOutsideRangeFn = (
timeSlots,
startDate,
endDate,
previousStartDate,
focusedInput,
unitType
) => {
export const isOutsideRangeFn = (timeSlots, startDate, endDate, focusedInput, unitType) => {
const endOfRange = config.dayCountAvailableForBooking - 1;
const lastBookableDate = moment().add(endOfRange, 'days');

View file

@ -123,7 +123,6 @@ class DateRangeInputComponent extends Component {
this.state = {
focusedInput: null,
currentStartDate: null,
previousStartDate: null,
};
this.blurTimeoutId = null;
@ -145,14 +144,29 @@ class DateRangeInputComponent extends Component {
}
onDatesChange(dates) {
const { unitType } = this.props;
const { unitType, timeSlots } = this.props;
const { startDate, endDate } = dates;
const startDateAsDate = startDate instanceof moment ? startDate.toDate() : null;
const endDateAsDate = pickerEndDateToApiDate(unitType, endDate);
this.setState(prevState => ({
// both dates are selected, a new start date before the previous start
// date is selected
const startDateUpdated =
timeSlots &&
startDate &&
endDate &&
this.state.currentStartDate &&
startDate.isBefore(this.state.currentStartDate);
// clear the end date in case a blocked date can be found
// between previous start date and new start date
const clearEndDate = startDateUpdated
? isBlockedBetween(timeSlots, startDate, moment(this.state.currentStartDate).add(1, 'days'))
: false;
const startDateAsDate = startDate instanceof moment ? startDate.toDate() : null;
const endDateAsDate = clearEndDate ? null : pickerEndDateToApiDate(unitType, endDate);
this.setState(() => ({
currentStartDate: startDateAsDate,
previousStartDate: prevState.currentStartDate,
}));
this.props.onChange({ startDate: startDateAsDate, endDate: endDateAsDate });
@ -206,30 +220,18 @@ class DateRangeInputComponent extends Component {
const endDate =
apiEndDateToPickerDate(unitType, value ? value.endDate : null) || initialEndMoment;
// both dates are selected, a new start date before the previous start
// date is selected
const startDateUpdated =
timeSlots &&
startDate &&
endDate &&
this.state.previousStartDate &&
startDate.isBefore(this.state.previousStartDate);
// clear the end date in case a blocked date can be found
// between previous start date and new start date
const clearEndDate = startDateUpdated
? isBlockedBetween(timeSlots, startDate, moment(this.state.previousStartDate).add(1, 'days'))
: false;
const endDateMaybe = clearEndDate ? null : endDate;
let isDayBlocked = isDayBlockedFn(timeSlots, startDate, endDate, this.state.focusedInput);
let isDayBlocked = isDayBlockedFn(
timeSlots,
startDate,
endDate,
this.state.focusedInput,
unitType
);
let isOutsideRange = isOutsideRangeFn(
timeSlots,
startDate,
endDateMaybe,
this.state.previousStartDate,
endDate,
this.state.focusedInput,
unitType
);
@ -261,7 +263,7 @@ class DateRangeInputComponent extends Component {
focusedInput={this.state.focusedInput}
onFocusChange={this.onFocusChange}
startDate={startDate}
endDate={endDateMaybe}
endDate={endDate}
minimumNights={isDaily ? 0 : 1}
onDatesChange={this.onDatesChange}
startDatePlaceholderText={startDatePlaceholderTxt}

View file

@ -4,7 +4,7 @@ import { Form as FinalForm, FormSpy } from 'react-final-form';
import moment from 'moment';
import { Button } from '../../components';
import { required, bookingDatesRequired, composeValidators } from '../../util/validators';
import { LINE_ITEM_NIGHT } from '../../util/types';
import { LINE_ITEM_NIGHT, LINE_ITEM_DAY } from '../../util/types';
import { createTimeSlots } from '../../util/test-data';
import FieldDateRangeInput from './FieldDateRangeInput';
@ -62,7 +62,7 @@ export const Empty = {
dateInputProps: {
name: 'bookingDates',
unitType: LINE_ITEM_NIGHT,
startDateId: 'EmptyDateRangeInputForm.bookingStartDate',
startDateId: 'EmptyDateRange.bookingStartDate',
startDateLabel: 'Start date',
startDatePlaceholderText: moment().format('ddd, MMMM D'),
endDateId: 'EmptyDateRangeInputForm.bookingEndDate',
@ -91,13 +91,50 @@ export const Empty = {
group: 'custom inputs',
};
export const WithAvailableTimeSlots = {
export const WithAvailableTimeSlotsNighlyBooking = {
component: FormComponent,
props: {
style: { marginBottom: '140px' },
dateInputProps: {
name: 'bookingDates',
unitType: LINE_ITEM_NIGHT,
startDateId: 'WithAvailableTimeSlotsDateRangeInputForm.bookingStartDate',
startDateId: 'WithAvailableTimeSlotsDateRangeNightly.bookingStartDate',
startDateLabel: 'Start date',
startDatePlaceholderText: moment().format('ddd, MMMM D'),
endDateId: 'WithAvailableTimeSlotsDateRangeInputForm.bookingEndDate',
endDateLabel: 'End date',
endDatePlaceholderText: moment()
.add(1, 'days')
.format('ddd, MMMM D'),
format: null,
timeSlots: createAvailableTimeSlots(90, 60),
validate: composeValidators(
required('Required'),
bookingDatesRequired('Start date is not valid', 'End date is not valid')
),
onBlur: () => console.log('onBlur called from DateRangeInput props.'),
onFocus: () => console.log('onFocus called from DateRangeInput props.'),
},
onChange: formState => {
const { startDate, endDate } = formState.values;
if (startDate || endDate) {
console.log('Changed to', moment(startDate).format('L'), moment(endDate).format('L'));
}
},
onSubmit: values => {
console.log('Submitting a form with values:', values);
},
},
group: 'custom inputs',
};
export const WithAvailableTimeSlotsDailyBooking = {
component: FormComponent,
props: {
dateInputProps: {
name: 'bookingDates',
unitType: LINE_ITEM_DAY,
startDateId: 'WithAvailableTimeSlotsDateRangeDaily.bookingStartDate',
startDateLabel: 'Start date',
startDatePlaceholderText: moment().format('ddd, MMMM D'),
endDateId: 'WithAvailableTimeSlotsDateRangeInputForm.bookingEndDate',

View file

@ -38,7 +38,7 @@ const bookingUnitType = 'line-item/night';
// Should the application fetch available time slots (currently defined as
// start and end dates) to be shown on listing page.
const fetchAvailableTimeSlots = false;
const fetchAvailableTimeSlots = true;
// A maximum number of days forwards during which a booking can be made.
// This is limited due to Stripe holding funds up to 90 days from the

View file

@ -15,6 +15,7 @@ import {
isTransactionInitiateAmountTooLowError,
isTransactionInitiateListingNotFoundError,
isTransactionInitiateMissingStripeAccountError,
isTransactionInitiateBookingTimeNotAvailableError,
} from '../../util/errors';
import {
AvatarMedium,
@ -269,6 +270,10 @@ export class CheckoutPageComponent extends Component {
);
const isAmountTooLowError = isTransactionInitiateAmountTooLowError(initiateOrderError);
const isBookingTimeNotAvailableError = isTransactionInitiateBookingTimeNotAvailableError(
initiateOrderError
);
let initiateOrderErrorMessage = null;
if (!listingNotFound && isAmountTooLowError) {
@ -277,6 +282,12 @@ export class CheckoutPageComponent extends Component {
<FormattedMessage id="CheckoutPage.initiateOrderAmountTooLow" />
</p>
);
} else if (!listingNotFound && isBookingTimeNotAvailableError) {
initiateOrderErrorMessage = (
<p className={css.orderError}>
<FormattedMessage id="CheckoutPage.bookingTimeNotAvailableMessage" />
</p>
);
} else if (!listingNotFound && initiateOrderError) {
initiateOrderErrorMessage = (
<p className={css.orderError}>
@ -298,6 +309,12 @@ export class CheckoutPageComponent extends Component {
<FormattedMessage id="CheckoutPage.providerStripeAccountMissingError" />
</p>
);
} else if (isTransactionInitiateBookingTimeNotAvailableError(speculateTransactionError)) {
speculateErrorMessage = (
<p className={css.orderError}>
<FormattedMessage id="CheckoutPage.bookingTimeNotAvailableMessage" />
</p>
);
} else if (speculateTransactionError) {
speculateErrorMessage = (
<p className={css.orderError}>

View file

@ -37,7 +37,7 @@ const initialState = {
showListingError: null,
reviews: [],
fetchReviewsError: null,
timeSlots: [],
timeSlots: null,
fetchTimesLotsError: null,
sendEnquiryInProgress: false,
sendEnquiryError: null,

View file

@ -197,6 +197,7 @@ export class ListingPageComponent extends Component {
fetchReviewsError,
sendEnquiryInProgress,
sendEnquiryError,
timeSlots,
categoriesConfig,
amenitiesConfig,
} = this.props;
@ -477,6 +478,7 @@ export class ListingPageComponent extends Component {
handleBookButtonClick={handleBookButtonClick}
handleMobileBookModalClose={handleMobileBookModalClose}
onManageDisableScrolling={onManageDisableScrolling}
timeSlots={timeSlots}
/>
</div>
</div>

View file

@ -24,6 +24,7 @@ const SectionBooking = props => {
handleBookButtonClick,
handleMobileBookModalClose,
onManageDisableScrolling,
timeSlots,
} = props;
const showClosedListingHelpText = listing.id && isClosed;
return (
@ -68,6 +69,7 @@ const SectionBooking = props => {
onSubmit={handleBookingSubmit}
price={price}
isOwnListing={isOwnListing}
timeSlots={timeSlots}
/>
) : null}
</ModalInMobile>

View file

@ -325,6 +325,7 @@ exports[`ListingPage matches snapshot 1`] = `
</span>
}
timeSlots={null}
unitType="line-item/night"
/>
</div>

View file

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { string, bool } from 'prop-types';
import { string, bool, arrayOf } from 'prop-types';
import { compose } from 'redux';
import { Form as FinalForm } from 'react-final-form';
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
@ -85,6 +85,7 @@ export class BookingDatesFormComponent extends Component {
unitPrice,
unitType,
values,
timeSlots,
} = fieldRenderProps;
const { startDate, endDate } = values && values.bookingDates ? values.bookingDates : {};
@ -160,6 +161,7 @@ export class BookingDatesFormComponent extends Component {
focusedInput={this.state.focusedInput}
onFocusedInputChange={this.onFocusedInputChange}
format={null}
timeSlots={timeSlots}
useMobileMargins
validate={composeValidators(
required(requiredMessage),
@ -197,6 +199,7 @@ BookingDatesFormComponent.defaultProps = {
isOwnListing: false,
startDatePlaceholder: null,
endDatePlaceholder: null,
timeSlots: null,
};
BookingDatesFormComponent.propTypes = {
@ -207,6 +210,7 @@ BookingDatesFormComponent.propTypes = {
unitType: propTypes.bookingUnitType.isRequired,
price: propTypes.money,
isOwnListing: bool,
timeSlots: arrayOf(propTypes.timeSlot),
// from injectIntl
intl: intlShape.isRequired,

View file

@ -22,6 +22,7 @@ exports[`BookingDatesForm matches snapshot without selected dates 1`] = `
render={[Function]}
startDatePlaceholder="today"
submitButtonWrapperClassName={null}
timeSlots={null}
unitPrice={
Money {
"amount": 1099,

View file

@ -66,6 +66,7 @@
"BookingDatesForm.requestToBook": "Request to book",
"BookingDatesForm.requiredDate": "Oops, make sure your date is correct!",
"BookingDatesForm.youWontBeChargedInfo": "You won't be charged yet",
"CheckoutPage.bookingTimeNotAvailableMessage": "Unfortunately, the requested time is already booked.",
"CheckoutPage.errorlistingLinkText": "the sauna page",
"CheckoutPage.goToLandingPage": "Go to homepage",
"CheckoutPage.hostedBy": "Hosted by {name}",

View file

@ -21,6 +21,7 @@ import {
ERROR_CODE_TOO_MANY_VERIFICATION_REQUESTS,
ERROR_CODE_UPLOAD_OVER_LIMIT,
ERROR_CODE_MISSING_STRIPE_ACCOUNT,
ERROR_CODE_TRANSACTION_BOOKING_TIME_NOT_AVAILABLE,
} from './types';
const errorAPIErrors = error => {
@ -102,6 +103,14 @@ export const isTransactionInitiateListingNotFoundError = error =>
export const isTransactionInitiateMissingStripeAccountError = error =>
hasErrorWithCode(error, ERROR_CODE_MISSING_STRIPE_ACCOUNT);
/**
* Check if the given API error (from `sdk.transaction.initiate()` or
* `sdk.transaction.initiateSpeculative()`) is due to selected booking
* time already being booked.
*/
export const isTransactionInitiateBookingTimeNotAvailableError = error =>
hasErrorWithCode(error, ERROR_CODE_TRANSACTION_BOOKING_TIME_NOT_AVAILABLE);
/**
* Check if the given API error (from `sdk.transaction.initiate()`) is
* due to the transaction total amount being too low for Stripe.

View file

@ -431,6 +431,8 @@ export const ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER =
'transaction-already-reviewed-by-customer';
export const ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER =
'transaction-already-reviewed-by-provider';
export const ERROR_CODE_TRANSACTION_BOOKING_TIME_NOT_AVAILABLE =
'transaction-booking-time-not-available';
export const ERROR_CODE_PAYMENT_FAILED = 'transaction-payment-failed';
export const ERROR_CODE_EMAIL_TAKEN = 'email-taken';
export const ERROR_CODE_EMAIL_NOT_FOUND = 'email-not-found';