mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
Merge pull request #892 from sharetribe/availability
Add availability support
This commit is contained in:
commit
f6fbae7dd8
15 changed files with 145 additions and 57 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const initialState = {
|
|||
showListingError: null,
|
||||
reviews: [],
|
||||
fetchReviewsError: null,
|
||||
timeSlots: [],
|
||||
timeSlots: null,
|
||||
fetchTimesLotsError: null,
|
||||
sendEnquiryInProgress: false,
|
||||
sendEnquiryError: null,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ exports[`ListingPage matches snapshot 1`] = `
|
|||
|
||||
</span>
|
||||
}
|
||||
timeSlots={null}
|
||||
unitType="line-item/night"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ exports[`BookingDatesForm matches snapshot without selected dates 1`] = `
|
|||
render={[Function]}
|
||||
startDatePlaceholder="today"
|
||||
submitButtonWrapperClassName={null}
|
||||
timeSlots={null}
|
||||
unitPrice={
|
||||
Money {
|
||||
"amount": 1099,
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue