diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9e14557..b5733b8a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/components/FieldDateRangeInput/DateRangeInput.helpers.js b/src/components/FieldDateRangeInput/DateRangeInput.helpers.js
index 8d2d5f13..1c540192 100644
--- a/src/components/FieldDateRangeInput/DateRangeInput.helpers.js
+++ b/src/components/FieldDateRangeInput/DateRangeInput.helpers.js
@@ -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');
diff --git a/src/components/FieldDateRangeInput/DateRangeInput.js b/src/components/FieldDateRangeInput/DateRangeInput.js
index 27485f1f..c3541e32 100644
--- a/src/components/FieldDateRangeInput/DateRangeInput.js
+++ b/src/components/FieldDateRangeInput/DateRangeInput.js
@@ -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}
diff --git a/src/components/FieldDateRangeInput/FieldDateRangeInput.example.js b/src/components/FieldDateRangeInput/FieldDateRangeInput.example.js
index 699c0669..28a1b194 100644
--- a/src/components/FieldDateRangeInput/FieldDateRangeInput.example.js
+++ b/src/components/FieldDateRangeInput/FieldDateRangeInput.example.js
@@ -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',
diff --git a/src/config.js b/src/config.js
index 3b94037a..7b219c62 100644
--- a/src/config.js
+++ b/src/config.js
@@ -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
diff --git a/src/containers/CheckoutPage/CheckoutPage.js b/src/containers/CheckoutPage/CheckoutPage.js
index 7581ff5c..a1d2597d 100644
--- a/src/containers/CheckoutPage/CheckoutPage.js
+++ b/src/containers/CheckoutPage/CheckoutPage.js
@@ -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 {
+
@@ -298,6 +309,12 @@ export class CheckoutPageComponent extends Component {
+
diff --git a/src/containers/ListingPage/ListingPage.duck.js b/src/containers/ListingPage/ListingPage.duck.js index 2dafe664..9ada66a1 100644 --- a/src/containers/ListingPage/ListingPage.duck.js +++ b/src/containers/ListingPage/ListingPage.duck.js @@ -37,7 +37,7 @@ const initialState = { showListingError: null, reviews: [], fetchReviewsError: null, - timeSlots: [], + timeSlots: null, fetchTimesLotsError: null, sendEnquiryInProgress: false, sendEnquiryError: null, diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 80af60e5..4d6eacc1 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -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} /> diff --git a/src/containers/ListingPage/SectionBooking.js b/src/containers/ListingPage/SectionBooking.js index 5efa683d..08cb4a6f 100644 --- a/src/containers/ListingPage/SectionBooking.js +++ b/src/containers/ListingPage/SectionBooking.js @@ -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} diff --git a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap index ef469cdf..4ec39bef 100644 --- a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap +++ b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap @@ -325,6 +325,7 @@ exports[`ListingPage matches snapshot 1`] = ` } + timeSlots={null} unitType="line-item/night" /> diff --git a/src/forms/BookingDatesForm/BookingDatesForm.js b/src/forms/BookingDatesForm/BookingDatesForm.js index 285ccf92..a127a951 100644 --- a/src/forms/BookingDatesForm/BookingDatesForm.js +++ b/src/forms/BookingDatesForm/BookingDatesForm.js @@ -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, diff --git a/src/forms/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap b/src/forms/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap index 7a310e10..e9b67861 100644 --- a/src/forms/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap +++ b/src/forms/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap @@ -22,6 +22,7 @@ exports[`BookingDatesForm matches snapshot without selected dates 1`] = ` render={[Function]} startDatePlaceholder="today" submitButtonWrapperClassName={null} + timeSlots={null} unitPrice={ Money { "amount": 1099, diff --git a/src/translations/en.json b/src/translations/en.json index 06b662aa..15cacf57 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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}", diff --git a/src/util/errors.js b/src/util/errors.js index 3fd07d8f..732d52bd 100644 --- a/src/util/errors.js +++ b/src/util/errors.js @@ -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. diff --git a/src/util/types.js b/src/util/types.js index aa56750a..81a4daa4 100644 --- a/src/util/types.js +++ b/src/util/types.js @@ -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';