mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-27 09:13:14 +10:00
Merge pull request #972 from sharetribe/manage-availability
Manage Availability
This commit is contained in:
commit
ff682d97cf
30 changed files with 1843 additions and 10 deletions
|
|
@ -14,6 +14,11 @@ way to update this template, but currently, we follow a pattern:
|
|||
|
||||
## Upcoming version 2019-XX-XX
|
||||
|
||||
- Manage availability of listings. This works for listings that have booking unit type:
|
||||
'line-item/night', or 'line-item/day'. There's also 'manage availability' link in the
|
||||
ManageListingCards of "your listings" page.
|
||||
[#972](https://github.com/sharetribe/flex-template-web/pull/972)
|
||||
|
||||
## [v2.6.0] 2019-01-02
|
||||
|
||||
- [fix] Wrong translations for perUnit in fr.json.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 11px 24px 0 24px;
|
||||
}
|
||||
|
||||
.form {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
|
||||
@media (--viewportLarge) {
|
||||
.title {
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
import { bool, func, object, shape, string } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { ensureOwnListing } from '../../util/data';
|
||||
import { LISTING_STATE_DRAFT } from '../../util/types';
|
||||
import { ListingLink } from '../../components';
|
||||
import { EditListingAvailabilityForm } from '../../forms';
|
||||
|
||||
import css from './EditListingAvailabilityPanel.css';
|
||||
|
||||
const EditListingAvailabilityPanel = props => {
|
||||
const {
|
||||
className,
|
||||
rootClassName,
|
||||
listing,
|
||||
availability,
|
||||
onSubmit,
|
||||
onChange,
|
||||
submitButtonText,
|
||||
panelUpdated,
|
||||
updateInProgress,
|
||||
errors,
|
||||
} = props;
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const currentListing = ensureOwnListing(listing);
|
||||
const isPublished = currentListing.id && currentListing.attributes.state !== LISTING_STATE_DRAFT;
|
||||
const defaultAvailabilityPlan = {
|
||||
type: 'availability-plan/day',
|
||||
entries: [
|
||||
{ dayOfWeek: 'mon', seats: 1 },
|
||||
{ dayOfWeek: 'tue', seats: 1 },
|
||||
{ dayOfWeek: 'wed', seats: 1 },
|
||||
{ dayOfWeek: 'thu', seats: 1 },
|
||||
{ dayOfWeek: 'fri', seats: 1 },
|
||||
{ dayOfWeek: 'sat', seats: 1 },
|
||||
{ dayOfWeek: 'sun', seats: 1 },
|
||||
],
|
||||
};
|
||||
const availabilityPlan = currentListing.attributes.availabilityPlan || defaultAvailabilityPlan;
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<h1 className={css.title}>
|
||||
{isPublished ? (
|
||||
<FormattedMessage
|
||||
id="EditListingAvailabilityPanel.title"
|
||||
values={{ listingTitle: <ListingLink listing={listing} /> }}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage id="EditListingAvailabilityPanel.createListingTitle" />
|
||||
)}
|
||||
</h1>
|
||||
<EditListingAvailabilityForm
|
||||
className={css.form}
|
||||
listingId={currentListing.id}
|
||||
initialValues={{ availabilityPlan }}
|
||||
availability={availability}
|
||||
availabilityPlan={availabilityPlan}
|
||||
onSubmit={() => {
|
||||
// We save the default availability plan
|
||||
// I.e. this listing is available every night.
|
||||
// Exceptions are handled with live edit through a calendar,
|
||||
// which is visible on this panel.
|
||||
onSubmit({ availabilityPlan });
|
||||
}}
|
||||
onChange={onChange}
|
||||
saveActionMsg={submitButtonText}
|
||||
updated={panelUpdated}
|
||||
updateError={errors.updateListingError}
|
||||
updateInProgress={updateInProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EditListingAvailabilityPanel.defaultProps = {
|
||||
className: null,
|
||||
rootClassName: null,
|
||||
listing: null,
|
||||
};
|
||||
|
||||
EditListingAvailabilityPanel.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
|
||||
// We cannot use propTypes.listing since the listing might be a draft.
|
||||
listing: object,
|
||||
|
||||
availability: shape({
|
||||
calendar: object.isRequired,
|
||||
onFetchAvailabilityExceptions: func.isRequired,
|
||||
onCreateAvailabilityException: func.isRequired,
|
||||
onDeleteAvailabilityException: func.isRequired,
|
||||
}).isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
onChange: func.isRequired,
|
||||
submitButtonText: string.isRequired,
|
||||
panelUpdated: bool.isRequired,
|
||||
updateInProgress: bool.isRequired,
|
||||
errors: object.isRequired,
|
||||
};
|
||||
|
||||
export default EditListingAvailabilityPanel;
|
||||
|
|
@ -6,6 +6,9 @@
|
|||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Content of EditListingWizard should have smaller z-index than Topbar */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
|
|
@ -82,7 +85,7 @@
|
|||
flex-grow: 1;
|
||||
|
||||
@media (--viewportLarge) {
|
||||
padding: 90px 15vw 82px 82px;
|
||||
padding: 82px 15vw 82px 82px;
|
||||
border-left: 1px solid var(--matterColorNegative);
|
||||
background-color: var(--matterColorLight);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { PayoutDetailsForm } from '../../forms';
|
|||
import { Modal, NamedRedirect, Tabs } from '../../components';
|
||||
|
||||
import EditListingWizardTab, {
|
||||
AVAILABILITY,
|
||||
DESCRIPTION,
|
||||
FEATURES,
|
||||
POLICY,
|
||||
|
|
@ -25,7 +26,7 @@ import css from './EditListingWizard.css';
|
|||
|
||||
// TODO: PHOTOS panel needs to be the last one since it currently contains PayoutDetailsForm modal
|
||||
// All the other panels can be reordered.
|
||||
export const TABS = [DESCRIPTION, FEATURES, POLICY, LOCATION, PRICING, PHOTOS];
|
||||
export const TABS = [DESCRIPTION, FEATURES, POLICY, LOCATION, PRICING, AVAILABILITY, PHOTOS];
|
||||
|
||||
// Tabs are horizontal in small screens
|
||||
const MAX_HORIZONTAL_NAV_SCREEN_WIDTH = 1023;
|
||||
|
|
@ -42,6 +43,8 @@ const tabLabel = (intl, tab) => {
|
|||
key = 'EditListingWizard.tabLabelLocation';
|
||||
} else if (tab === PRICING) {
|
||||
key = 'EditListingWizard.tabLabelPricing';
|
||||
} else if (tab === AVAILABILITY) {
|
||||
key = 'EditListingWizard.tabLabelAvailability';
|
||||
} else if (tab === PHOTOS) {
|
||||
key = 'EditListingWizard.tabLabelPhotos';
|
||||
}
|
||||
|
|
@ -58,7 +61,14 @@ const tabLabel = (intl, tab) => {
|
|||
* @return true if tab / step is completed.
|
||||
*/
|
||||
const tabCompleted = (tab, listing) => {
|
||||
const { description, geolocation, price, title, publicData } = listing.attributes;
|
||||
const {
|
||||
availabilityPlan,
|
||||
description,
|
||||
geolocation,
|
||||
price,
|
||||
title,
|
||||
publicData,
|
||||
} = listing.attributes;
|
||||
const images = listing.images;
|
||||
|
||||
switch (tab) {
|
||||
|
|
@ -72,6 +82,8 @@ const tabCompleted = (tab, listing) => {
|
|||
return !!(geolocation && publicData && publicData.location && publicData.location.address);
|
||||
case PRICING:
|
||||
return !!price;
|
||||
case AVAILABILITY:
|
||||
return !!availabilityPlan;
|
||||
case PHOTOS:
|
||||
return images && images.length > 0;
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { ensureListing } from '../../util/data';
|
||||
import { createResourceLocatorString } from '../../util/routes';
|
||||
import {
|
||||
EditListingAvailabilityPanel,
|
||||
EditListingDescriptionPanel,
|
||||
EditListingFeaturesPanel,
|
||||
EditListingLocationPanel,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
|
||||
import css from './EditListingWizard.css';
|
||||
|
||||
export const AVAILABILITY = 'availability';
|
||||
export const DESCRIPTION = 'description';
|
||||
export const FEATURES = 'features';
|
||||
export const POLICY = 'policy';
|
||||
|
|
@ -28,7 +30,15 @@ export const PRICING = 'pricing';
|
|||
export const PHOTOS = 'photos';
|
||||
|
||||
// EditListingWizardTab component supports these tabs
|
||||
export const SUPPORTED_TABS = [DESCRIPTION, FEATURES, POLICY, LOCATION, PRICING, PHOTOS];
|
||||
export const SUPPORTED_TABS = [
|
||||
DESCRIPTION,
|
||||
FEATURES,
|
||||
POLICY,
|
||||
LOCATION,
|
||||
PRICING,
|
||||
AVAILABILITY,
|
||||
PHOTOS,
|
||||
];
|
||||
|
||||
const pathParamsToNextTab = (params, tab, marketplaceTabs) => {
|
||||
const nextTabIndex = marketplaceTabs.findIndex(s => s === tab) + 1;
|
||||
|
|
@ -71,6 +81,7 @@ const EditListingWizardTab = props => {
|
|||
newListingPublished,
|
||||
history,
|
||||
images,
|
||||
availability,
|
||||
listing,
|
||||
handleCreateFlowTabScrolling,
|
||||
handlePublishListing,
|
||||
|
|
@ -213,6 +224,21 @@ const EditListingWizardTab = props => {
|
|||
/>
|
||||
);
|
||||
}
|
||||
case AVAILABILITY: {
|
||||
const submitButtonTranslationKey = isNewListingFlow
|
||||
? 'EditListingWizard.saveNewAvailability'
|
||||
: 'EditListingWizard.saveEditAvailability';
|
||||
return (
|
||||
<EditListingAvailabilityPanel
|
||||
{...panelProps(AVAILABILITY)}
|
||||
availability={availability}
|
||||
submitButtonText={intl.formatMessage({ id: submitButtonTranslationKey })}
|
||||
onSubmit={values => {
|
||||
onCompleteEditListingWizardTab(tab, values);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case PHOTOS: {
|
||||
const submitButtonTranslationKey = isNewListingFlow
|
||||
? 'EditListingWizard.saveNewPhotos'
|
||||
|
|
@ -268,6 +294,7 @@ EditListingWizardTab.propTypes = {
|
|||
replace: func.isRequired,
|
||||
}).isRequired,
|
||||
images: array.isRequired,
|
||||
availability: object.isRequired,
|
||||
|
||||
// We cannot use propTypes.listing since the listing might be a draft.
|
||||
listing: shape({
|
||||
|
|
|
|||
|
|
@ -334,6 +334,16 @@ export const ManageListingCardComponent = props => {
|
|||
>
|
||||
<FormattedMessage id="ManageListingCard.editListing" />
|
||||
</NamedLink>
|
||||
|
||||
<span className={css.manageLinksSeparator}>{' • '}</span>
|
||||
|
||||
<NamedLink
|
||||
className={css.manageLink}
|
||||
name="EditListingPage"
|
||||
params={{ id, slug, type: 'edit', tab: 'availability' }}
|
||||
>
|
||||
<FormattedMessage id="ManageListingCard.manageAvailability" />
|
||||
</NamedLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -144,6 +144,25 @@ exports[`ManageListingCard matches snapshot 1`] = `
|
|||
values={Object {}}
|
||||
/>
|
||||
</NamedLink>
|
||||
<span>
|
||||
•
|
||||
</span>
|
||||
<NamedLink
|
||||
name="EditListingPage"
|
||||
params={
|
||||
Object {
|
||||
"id": "listing1",
|
||||
"slug": "listing1-title",
|
||||
"tab": "availability",
|
||||
"type": "edit",
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="ManageListingCard.manageAvailability"
|
||||
values={Object {}}
|
||||
/>
|
||||
</NamedLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export { default as Button, PrimaryButton, SecondaryButton, InlineTextButton } f
|
|||
export { default as BookingPanel } from './BookingPanel/BookingPanel';
|
||||
export { default as CookieConsent } from './CookieConsent/CookieConsent';
|
||||
export { default as Discussion } from './Discussion/Discussion';
|
||||
export { default as EditListingAvailabilityPanel } from './EditListingAvailabilityPanel/EditListingAvailabilityPanel';
|
||||
export { default as EditListingDescriptionPanel } from './EditListingDescriptionPanel/EditListingDescriptionPanel';
|
||||
export { default as EditListingFeaturesPanel } from './EditListingFeaturesPanel/EditListingFeaturesPanel';
|
||||
export { default as EditListingLocationPanel } from './EditListingLocationPanel/EditListingLocationPanel';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,89 @@
|
|||
import omit from 'lodash/omit';
|
||||
import { types as sdkTypes } from '../../util/sdkLoader';
|
||||
import { denormalisedResponseEntities, ensureAvailabilityException } from '../../util/data';
|
||||
import { isSameDate, monthIdString } from '../../util/dates';
|
||||
import { storableError } from '../../util/errors';
|
||||
import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck';
|
||||
import * as log from '../../util/log';
|
||||
|
||||
const { UUID } = sdkTypes;
|
||||
|
||||
// A helper function to filter away exception that matches start and end timestamps
|
||||
const removeException = (exception, calendar) => {
|
||||
const availabilityException = ensureAvailabilityException(exception.availabilityException);
|
||||
const { start, end } = availabilityException.attributes;
|
||||
const monthId = monthIdString(start);
|
||||
const monthData = calendar[monthId] || { exceptions: [] };
|
||||
|
||||
const exceptions = monthData.exceptions.filter(e => {
|
||||
const anException = ensureAvailabilityException(e.availabilityException);
|
||||
const exceptionStart = anException.attributes.start;
|
||||
const exceptionEnd = anException.attributes.end;
|
||||
|
||||
return !(isSameDate(exceptionStart, start) && isSameDate(exceptionEnd, end));
|
||||
});
|
||||
|
||||
return {
|
||||
...calendar,
|
||||
[monthId]: { ...monthData, exceptions },
|
||||
};
|
||||
};
|
||||
|
||||
// A helper function to add a new exception and remove previous one if there's a matching exception
|
||||
const addException = (exception, calendar) => {
|
||||
const { start } = ensureAvailabilityException(exception.availabilityException).attributes;
|
||||
const monthId = monthIdString(start);
|
||||
|
||||
// TODO: API doesn't support "availability_exceptions/update" yet
|
||||
// So, when user wants to create an exception we need to ensure
|
||||
// that possible existing exception is removed first.
|
||||
const cleanCalendar = removeException(exception, calendar);
|
||||
const monthData = cleanCalendar[monthId] || { exceptions: [] };
|
||||
|
||||
return {
|
||||
...cleanCalendar,
|
||||
[monthId]: { ...monthData, exceptions: [...monthData.exceptions, exception] },
|
||||
};
|
||||
};
|
||||
|
||||
// A helper function to update exception that matches start and end timestamps
|
||||
const updateException = (exception, calendar) => {
|
||||
const newAvailabilityException = ensureAvailabilityException(exception.availabilityException);
|
||||
const { start, end } = newAvailabilityException.attributes;
|
||||
const monthId = monthIdString(start);
|
||||
const monthData = calendar[monthId] || { exceptions: [] };
|
||||
|
||||
const exceptions = monthData.exceptions.map(e => {
|
||||
const availabilityException = ensureAvailabilityException(e.availabilityException);
|
||||
const exceptionStart = availabilityException.attributes.start;
|
||||
const exceptionEnd = availabilityException.attributes.end;
|
||||
|
||||
return isSameDate(exceptionStart, start) && isSameDate(exceptionEnd, end) ? exception : e;
|
||||
});
|
||||
|
||||
return {
|
||||
...calendar,
|
||||
[monthId]: { ...monthData, exceptions },
|
||||
};
|
||||
};
|
||||
|
||||
// Update calendar data of given month
|
||||
const updateCalendarMonth = (state, monthId, data) => {
|
||||
// Ensure that every month has array for bookings and exceptions
|
||||
const defaultMonthData = { bookings: [], exceptions: [] };
|
||||
return {
|
||||
...state,
|
||||
availabilityCalendar: {
|
||||
...state.availabilityCalendar,
|
||||
[monthId]: {
|
||||
...defaultMonthData,
|
||||
...state.availabilityCalendar[monthId],
|
||||
...data,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const requestAction = actionType => params => ({ type: actionType, payload: { params } });
|
||||
|
||||
const successAction = actionType => result => ({ type: actionType, payload: result.data });
|
||||
|
|
@ -33,6 +111,22 @@ export const SHOW_LISTINGS_REQUEST = 'app/EditListingPage/SHOW_LISTINGS_REQUEST'
|
|||
export const SHOW_LISTINGS_SUCCESS = 'app/EditListingPage/SHOW_LISTINGS_SUCCESS';
|
||||
export const SHOW_LISTINGS_ERROR = 'app/EditListingPage/SHOW_LISTINGS_ERROR';
|
||||
|
||||
export const FETCH_BOOKINGS_REQUEST = 'app/EditListingPage/FETCH_BOOKINGS_REQUEST';
|
||||
export const FETCH_BOOKINGS_SUCCESS = 'app/EditListingPage/FETCH_BOOKINGS_SUCCESS';
|
||||
export const FETCH_BOOKINGS_ERROR = 'app/EditListingPage/FETCH_BOOKINGS_ERROR';
|
||||
|
||||
export const FETCH_EXCEPTIONS_REQUEST = 'app/EditListingPage/FETCH_AVAILABILITY_EXCEPTIONS_REQUEST';
|
||||
export const FETCH_EXCEPTIONS_SUCCESS = 'app/EditListingPage/FETCH_AVAILABILITY_EXCEPTIONS_SUCCESS';
|
||||
export const FETCH_EXCEPTIONS_ERROR = 'app/EditListingPage/FETCH_AVAILABILITY_EXCEPTIONS_ERROR';
|
||||
|
||||
export const CREATE_EXCEPTION_REQUEST = 'app/EditListingPage/CREATE_AVAILABILITY_EXCEPTION_REQUEST';
|
||||
export const CREATE_EXCEPTION_SUCCESS = 'app/EditListingPage/CREATE_AVAILABILITY_EXCEPTION_SUCCESS';
|
||||
export const CREATE_EXCEPTION_ERROR = 'app/EditListingPage/CREATE_AVAILABILITY_EXCEPTION_ERROR';
|
||||
|
||||
export const DELETE_EXCEPTION_REQUEST = 'app/EditListingPage/DELETE_AVAILABILITY_EXCEPTION_REQUEST';
|
||||
export const DELETE_EXCEPTION_SUCCESS = 'app/EditListingPage/DELETE_AVAILABILITY_EXCEPTION_SUCCESS';
|
||||
export const DELETE_EXCEPTION_ERROR = 'app/EditListingPage/DELETE_AVAILABILITY_EXCEPTION_ERROR';
|
||||
|
||||
export const UPLOAD_IMAGE_REQUEST = 'app/EditListingPage/UPLOAD_IMAGE_REQUEST';
|
||||
export const UPLOAD_IMAGE_SUCCESS = 'app/EditListingPage/UPLOAD_IMAGE_SUCCESS';
|
||||
export const UPLOAD_IMAGE_ERROR = 'app/EditListingPage/UPLOAD_IMAGE_ERROR';
|
||||
|
|
@ -54,6 +148,16 @@ const initialState = {
|
|||
createListingDraftInProgress: false,
|
||||
submittedListingId: null,
|
||||
redirectToListing: false,
|
||||
availabilityCalendar: {
|
||||
// '2018-12': {
|
||||
// bookings: [],
|
||||
// exceptions: [],
|
||||
// fetchExceptionsError: null,
|
||||
// fetchExceptionsInProgress: false,
|
||||
// fetchBookingsError: null,
|
||||
// fetchBookingsInProgress: false,
|
||||
// },
|
||||
},
|
||||
images: {},
|
||||
imageOrder: [],
|
||||
removedImageIds: [],
|
||||
|
|
@ -127,12 +231,92 @@ export default function reducer(state = initialState, action = {}) {
|
|||
case SHOW_LISTINGS_REQUEST:
|
||||
return { ...state, showListingsError: null };
|
||||
case SHOW_LISTINGS_SUCCESS:
|
||||
return initialState;
|
||||
return { ...initialState, availabilityCalendar: { ...state.availabilityCalendar } };
|
||||
|
||||
case SHOW_LISTINGS_ERROR:
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(payload);
|
||||
return { ...state, showListingsError: payload, redirectToListing: false };
|
||||
|
||||
case FETCH_BOOKINGS_REQUEST:
|
||||
return updateCalendarMonth(state, payload.params.monthId, {
|
||||
fetchBookingsError: null,
|
||||
fetchBookingsInProgress: true,
|
||||
});
|
||||
case FETCH_BOOKINGS_SUCCESS:
|
||||
return updateCalendarMonth(state, payload.monthId, {
|
||||
bookings: payload.bookings,
|
||||
fetchBookingsInProgress: false,
|
||||
});
|
||||
case FETCH_BOOKINGS_ERROR:
|
||||
return updateCalendarMonth(state, payload.monthId, {
|
||||
fetchBookingsError: payload.error,
|
||||
fetchBookingsInProgress: false,
|
||||
});
|
||||
|
||||
case FETCH_EXCEPTIONS_REQUEST:
|
||||
return updateCalendarMonth(state, payload.params.monthId, {
|
||||
fetchExceptionsError: null,
|
||||
fetchExceptionsInProgress: true,
|
||||
});
|
||||
case FETCH_EXCEPTIONS_SUCCESS:
|
||||
return updateCalendarMonth(state, payload.monthId, {
|
||||
exceptions: payload.exceptions,
|
||||
fetchExceptionsInProgress: false,
|
||||
});
|
||||
case FETCH_EXCEPTIONS_ERROR:
|
||||
return updateCalendarMonth(state, payload.monthId, {
|
||||
fetchExceptionsError: payload.error,
|
||||
fetchExceptionsInProgress: false,
|
||||
});
|
||||
|
||||
case CREATE_EXCEPTION_REQUEST: {
|
||||
const { start, end, seats } = payload.params;
|
||||
const draft = ensureAvailabilityException({ attributes: { start, end, seats } });
|
||||
const exception = { availabilityException: draft, inProgress: true };
|
||||
const availabilityCalendar = addException(exception, state.availabilityCalendar);
|
||||
return { ...state, availabilityCalendar };
|
||||
}
|
||||
case CREATE_EXCEPTION_SUCCESS: {
|
||||
const availabilityCalendar = updateException(payload.exception, state.availabilityCalendar);
|
||||
return { ...state, availabilityCalendar };
|
||||
}
|
||||
case CREATE_EXCEPTION_ERROR: {
|
||||
const { availabilityException, error } = payload;
|
||||
const failedException = { availabilityException, error };
|
||||
const availabilityCalendar = updateException(failedException, state.availabilityCalendar);
|
||||
return { ...state, availabilityCalendar };
|
||||
}
|
||||
|
||||
case DELETE_EXCEPTION_REQUEST: {
|
||||
const { id, seats, currentException } = payload.params;
|
||||
|
||||
// We first create temporary exception with given 'seats' count (the default after deletion).
|
||||
// This makes it possible to show the UI element immediately with default color that matches
|
||||
// with the availability plan.
|
||||
const exception = {
|
||||
id,
|
||||
inProgress: true,
|
||||
availabilityException: {
|
||||
...currentException.availabilityException,
|
||||
attributes: { ...currentException.availabilityException.attributes, seats },
|
||||
},
|
||||
};
|
||||
|
||||
const availabilityCalendar = updateException(exception, state.availabilityCalendar);
|
||||
return { ...state, availabilityCalendar };
|
||||
}
|
||||
case DELETE_EXCEPTION_SUCCESS: {
|
||||
const availabilityCalendar = removeException(payload.exception, state.availabilityCalendar);
|
||||
return { ...state, availabilityCalendar };
|
||||
}
|
||||
case DELETE_EXCEPTION_ERROR: {
|
||||
const { availabilityException, error } = payload;
|
||||
const failedException = { availabilityException, error };
|
||||
const availabilityCalendar = updateException(failedException, state.availabilityCalendar);
|
||||
return { ...state, availabilityCalendar };
|
||||
}
|
||||
|
||||
case UPLOAD_IMAGE_REQUEST: {
|
||||
// payload.params: { id: 'tempId', file }
|
||||
const images = {
|
||||
|
|
@ -237,6 +421,26 @@ export const uploadImage = requestAction(UPLOAD_IMAGE_REQUEST);
|
|||
export const uploadImageSuccess = successAction(UPLOAD_IMAGE_SUCCESS);
|
||||
export const uploadImageError = errorAction(UPLOAD_IMAGE_ERROR);
|
||||
|
||||
// SDK method: bookings.query
|
||||
export const fetchBookingsRequest = requestAction(FETCH_BOOKINGS_REQUEST);
|
||||
export const fetchBookingsSuccess = successAction(FETCH_BOOKINGS_SUCCESS);
|
||||
export const fetchBookingsError = errorAction(FETCH_BOOKINGS_ERROR);
|
||||
|
||||
// SDK method: availabilityExceptions.query
|
||||
export const fetchAvailabilityExceptionsRequest = requestAction(FETCH_EXCEPTIONS_REQUEST);
|
||||
export const fetchAvailabilityExceptionsSuccess = successAction(FETCH_EXCEPTIONS_SUCCESS);
|
||||
export const fetchAvailabilityExceptionsError = errorAction(FETCH_EXCEPTIONS_ERROR);
|
||||
|
||||
// SDK method: availabilityExceptions.create
|
||||
export const createAvailabilityExceptionRequest = requestAction(CREATE_EXCEPTION_REQUEST);
|
||||
export const createAvailabilityExceptionSuccess = successAction(CREATE_EXCEPTION_SUCCESS);
|
||||
export const createAvailabilityExceptionError = errorAction(CREATE_EXCEPTION_ERROR);
|
||||
|
||||
// SDK method: availabilityExceptions.delete
|
||||
export const deleteAvailabilityExceptionRequest = requestAction(DELETE_EXCEPTION_REQUEST);
|
||||
export const deleteAvailabilityExceptionSuccess = successAction(DELETE_EXCEPTION_SUCCESS);
|
||||
export const deleteAvailabilityExceptionError = errorAction(DELETE_EXCEPTION_ERROR);
|
||||
|
||||
// ================ Thunk ================ //
|
||||
|
||||
export function requestShowListing(actionPayload) {
|
||||
|
|
@ -312,6 +516,100 @@ export function requestImageUpload(actionPayload) {
|
|||
};
|
||||
}
|
||||
|
||||
export const requestFetchBookings = fetchParams => (dispatch, getState, sdk) => {
|
||||
const { listingId, start, end } = fetchParams;
|
||||
const monthId = monthIdString(start);
|
||||
|
||||
dispatch(fetchBookingsRequest({ ...fetchParams, monthId }));
|
||||
|
||||
return sdk.bookings
|
||||
.query({ listingId, start, end }, { expand: true })
|
||||
.then(response => {
|
||||
const bookings = denormalisedResponseEntities(response);
|
||||
return dispatch(fetchBookingsSuccess({ data: { monthId, bookings } }));
|
||||
})
|
||||
.catch(e => {
|
||||
return dispatch(fetchBookingsError({ monthId, error: storableError(e) }));
|
||||
});
|
||||
};
|
||||
|
||||
export const requestFetchAvailabilityExceptions = fetchParams => (dispatch, getState, sdk) => {
|
||||
const { listingId, start, end } = fetchParams;
|
||||
const monthId = monthIdString(start);
|
||||
|
||||
dispatch(fetchAvailabilityExceptionsRequest({ ...fetchParams, monthId }));
|
||||
|
||||
return sdk.availabilityExceptions
|
||||
.query({ listingId, start, end }, { expand: true })
|
||||
.then(response => {
|
||||
const exceptions = denormalisedResponseEntities(response).map(availabilityException => ({
|
||||
availabilityException,
|
||||
}));
|
||||
return dispatch(fetchAvailabilityExceptionsSuccess({ data: { monthId, exceptions } }));
|
||||
})
|
||||
.catch(e => {
|
||||
return dispatch(fetchAvailabilityExceptionsError({ monthId, error: storableError(e) }));
|
||||
});
|
||||
};
|
||||
|
||||
export const requestCreateAvailabilityException = params => (dispatch, getState, sdk) => {
|
||||
const { currentException, ...createParams } = params;
|
||||
|
||||
dispatch(createAvailabilityExceptionRequest(createParams));
|
||||
|
||||
return sdk.availabilityExceptions
|
||||
.create(createParams, { expand: true })
|
||||
.then(response => {
|
||||
dispatch(
|
||||
createAvailabilityExceptionSuccess({
|
||||
data: {
|
||||
exception: {
|
||||
availabilityException: response.data.data,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
const availabilityException = currentException && currentException.availabilityException;
|
||||
return dispatch(
|
||||
createAvailabilityExceptionError({
|
||||
error: storableError(error),
|
||||
availabilityException,
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const requestDeleteAvailabilityException = params => (dispatch, getState, sdk) => {
|
||||
const { currentException, seats, ...deleteParams } = params;
|
||||
|
||||
dispatch(deleteAvailabilityExceptionRequest(params));
|
||||
|
||||
return sdk.availabilityExceptions
|
||||
.delete(deleteParams, { expand: true })
|
||||
.then(response => {
|
||||
dispatch(
|
||||
deleteAvailabilityExceptionSuccess({
|
||||
data: {
|
||||
exception: currentException,
|
||||
},
|
||||
})
|
||||
);
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
const availabilityException = currentException && currentException.availabilityException;
|
||||
return dispatch(
|
||||
deleteAvailabilityExceptionError({
|
||||
error: storableError(error),
|
||||
availabilityException,
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Update the given tab of the wizard with the given data. This saves
|
||||
// the data to the listing, and marks the tab updated so the UI can
|
||||
// display the state.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import { EditListingWizard, NamedRedirect, Page } from '../../components';
|
|||
import { TopbarContainer } from '../../containers';
|
||||
|
||||
import {
|
||||
requestFetchBookings,
|
||||
requestFetchAvailabilityExceptions,
|
||||
requestCreateAvailabilityException,
|
||||
requestDeleteAvailabilityException,
|
||||
requestCreateListingDraft,
|
||||
requestPublishListingDraft,
|
||||
requestUpdateListing,
|
||||
|
|
@ -44,6 +48,10 @@ export const EditListingPageComponent = props => {
|
|||
getOwnListing,
|
||||
history,
|
||||
intl,
|
||||
onFetchAvailabilityExceptions,
|
||||
onCreateAvailabilityException,
|
||||
onDeleteAvailabilityException,
|
||||
onFetchBookings,
|
||||
onCreateListingDraft,
|
||||
onPublishListingDraft,
|
||||
onUpdateListing,
|
||||
|
|
@ -157,6 +165,13 @@ export const EditListingPageComponent = props => {
|
|||
history={history}
|
||||
images={images}
|
||||
listing={currentListing}
|
||||
availability={{
|
||||
calendar: page.availabilityCalendar,
|
||||
onFetchAvailabilityExceptions,
|
||||
onCreateAvailabilityException,
|
||||
onDeleteAvailabilityException,
|
||||
onFetchBookings,
|
||||
}}
|
||||
onUpdateListing={onUpdateListing}
|
||||
onCreateListingDraft={onCreateListingDraft}
|
||||
onPublishListingDraft={onPublishListingDraft}
|
||||
|
|
@ -202,6 +217,8 @@ EditListingPageComponent.propTypes = {
|
|||
currentUser: propTypes.currentUser,
|
||||
fetchInProgress: bool.isRequired,
|
||||
getOwnListing: func.isRequired,
|
||||
onFetchAvailabilityExceptions: func.isRequired,
|
||||
onCreateAvailabilityException: func.isRequired,
|
||||
onCreateListingDraft: func.isRequired,
|
||||
onPublishListingDraft: func.isRequired,
|
||||
onImageUpload: func.isRequired,
|
||||
|
|
@ -253,6 +270,10 @@ const mapStateToProps = state => {
|
|||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onUpdateListing: (tab, values) => dispatch(requestUpdateListing(tab, values)),
|
||||
onFetchBookings: params => dispatch(requestFetchBookings(params)),
|
||||
onFetchAvailabilityExceptions: params => dispatch(requestFetchAvailabilityExceptions(params)),
|
||||
onCreateAvailabilityException: params => dispatch(requestCreateAvailabilityException(params)),
|
||||
onDeleteAvailabilityException: params => dispatch(requestDeleteAvailabilityException(params)),
|
||||
onCreateListingDraft: values => dispatch(requestCreateListingDraft(values)),
|
||||
onPublishListingDraft: listingId => dispatch(requestPublishListingDraft(listingId)),
|
||||
onImageUpload: data => dispatch(requestImageUpload(data)),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ describe('EditListingPageComponent', () => {
|
|||
intl={fakeIntl}
|
||||
onLogout={noop}
|
||||
onManageDisableScrolling={noop}
|
||||
onFetchBookings={noop}
|
||||
onFetchAvailabilityExceptions={noop}
|
||||
onCreateAvailabilityException={noop}
|
||||
onDeleteAvailabilityException={noop}
|
||||
onCreateListing={noop}
|
||||
onCreateListingDraft={noop}
|
||||
onPublishListingDraft={noop}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@ exports[`EditListingPageComponent matches snapshot 1`] = `
|
|||
>
|
||||
<withRouter(Connect(TopbarContainerComponent)) />
|
||||
<withViewport(InjectIntl(EditListingWizard))
|
||||
availability={
|
||||
Object {
|
||||
"calendar": undefined,
|
||||
"onCreateAvailabilityException": [Function],
|
||||
"onDeleteAvailabilityException": [Function],
|
||||
"onFetchAvailabilityExceptions": [Function],
|
||||
"onFetchBookings": [Function],
|
||||
}
|
||||
}
|
||||
currentUser={null}
|
||||
errors={
|
||||
Object {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import * as UserCard from './components/UserCard/UserCard.example';
|
|||
|
||||
// forms
|
||||
import * as BookingDatesForm from './forms/BookingDatesForm/BookingDatesForm.example';
|
||||
import * as EditListingAvailabilityForm from './forms/EditListingAvailabilityForm/EditListingAvailabilityForm.example';
|
||||
import * as EditListingDescriptionForm from './forms/EditListingDescriptionForm/EditListingDescriptionForm.example';
|
||||
import * as EditListingFeaturesForm from './forms/EditListingFeaturesForm/EditListingFeaturesForm.example';
|
||||
import * as EditListingLocationForm from './forms/EditListingLocationForm/EditListingLocationForm.example';
|
||||
|
|
@ -93,6 +94,7 @@ export {
|
|||
BookingPanel,
|
||||
Button,
|
||||
Colors,
|
||||
EditListingAvailabilityForm,
|
||||
EditListingDescriptionForm,
|
||||
EditListingFeaturesForm,
|
||||
EditListingLocationForm,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
/* Dimensions */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
/* Layout */
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--failColor);
|
||||
}
|
||||
|
||||
.calendarWrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
margin-top: auto;
|
||||
margin-bottom: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (--viewportMedium) {
|
||||
.root {
|
||||
padding-top: 2px;
|
||||
margin-top: -16px;
|
||||
}
|
||||
.title {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--viewportLarge) {
|
||||
.calendarWrapper {
|
||||
flex-grow: 0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.submitButton {
|
||||
display: inline-block;
|
||||
width: 241px;
|
||||
margin-top: 86px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/* eslint-disable no-console */
|
||||
import EditListingAvailabilityForm from './EditListingAvailabilityForm';
|
||||
|
||||
export const Empty = {
|
||||
component: EditListingAvailabilityForm,
|
||||
props: {
|
||||
onSubmit: values => {
|
||||
console.log('Submit EditListingAvailabilityForm with (unformatted) values:', values);
|
||||
},
|
||||
saveActionMsg: 'Save rules',
|
||||
updated: false,
|
||||
updateInProgress: false,
|
||||
availability: {
|
||||
calendar: {
|
||||
// '2018-12': {
|
||||
// bookings: [],
|
||||
// exceptions: [],
|
||||
// fetchExceptionsError: null,
|
||||
// fetchExceptionsInProgress: false,
|
||||
// fetchBookingsError: null,
|
||||
// fetchBookingsInProgress: false,
|
||||
// },
|
||||
},
|
||||
onCreateAvailabilityException: () => console.log('onCreateAvailabilityException called'),
|
||||
onDeleteAvailabilityException: () => console.log('onDeleteAvailabilityException called'),
|
||||
onFetchAvailabilityExceptions: () => console.log('onFetchAvailabilityExceptions called'),
|
||||
onFetchBookings: () => console.log('onFetchBookings called'),
|
||||
},
|
||||
},
|
||||
group: 'forms',
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import React, { Component } from 'react';
|
||||
import { bool, func, object, string } from 'prop-types';
|
||||
import { compose } from 'redux';
|
||||
import { Form as FinalForm } from 'react-final-form';
|
||||
import { intlShape, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { Form, Button } from '../../components';
|
||||
|
||||
import ManageAvailabilityCalendar from './ManageAvailabilityCalendar';
|
||||
import css from './EditListingAvailabilityForm.css';
|
||||
|
||||
export class EditListingAvailabilityFormComponent extends Component {
|
||||
render() {
|
||||
return (
|
||||
<FinalForm
|
||||
{...this.props}
|
||||
render={fieldRenderProps => {
|
||||
const {
|
||||
className,
|
||||
rootClassName,
|
||||
disabled,
|
||||
handleSubmit,
|
||||
//intl,
|
||||
invalid,
|
||||
pristine,
|
||||
saveActionMsg,
|
||||
updated,
|
||||
updateError,
|
||||
updateInProgress,
|
||||
availability,
|
||||
availabilityPlan,
|
||||
listingId,
|
||||
} = fieldRenderProps;
|
||||
|
||||
const errorMessage = updateError ? (
|
||||
<p className={css.error}>
|
||||
<FormattedMessage id="EditListingAvailabilityForm.updateFailed" />
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const submitReady = updated && pristine;
|
||||
const submitInProgress = updateInProgress;
|
||||
const submitDisabled = invalid || disabled || submitInProgress;
|
||||
|
||||
return (
|
||||
<Form className={classes} onSubmit={handleSubmit}>
|
||||
{errorMessage}
|
||||
<div className={css.calendarWrapper}>
|
||||
<ManageAvailabilityCalendar
|
||||
availability={availability}
|
||||
availabilityPlan={availabilityPlan}
|
||||
listingId={listingId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={css.submitButton}
|
||||
type="submit"
|
||||
inProgress={submitInProgress}
|
||||
disabled={submitDisabled}
|
||||
ready={submitReady}
|
||||
>
|
||||
{saveActionMsg}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditListingAvailabilityFormComponent.defaultProps = {
|
||||
updateError: null,
|
||||
};
|
||||
|
||||
EditListingAvailabilityFormComponent.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
saveActionMsg: string.isRequired,
|
||||
updated: bool.isRequired,
|
||||
updateError: propTypes.error,
|
||||
updateInProgress: bool.isRequired,
|
||||
availability: object.isRequired,
|
||||
};
|
||||
|
||||
export default compose(injectIntl)(EditListingAvailabilityFormComponent);
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// NOTE: renderdeep doesn't work due to map integration
|
||||
import React from 'react';
|
||||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { fakeIntl } from '../../util/test-data';
|
||||
import { EditListingAvailabilityFormComponent } from './EditListingAvailabilityForm';
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
describe('EditListingAvailabilityForm', () => {
|
||||
it('matches snapshot', () => {
|
||||
const tree = renderShallow(
|
||||
<EditListingAvailabilityFormComponent
|
||||
intl={fakeIntl}
|
||||
dispatch={noop}
|
||||
onSubmit={v => v}
|
||||
saveActionMsg="Save rules"
|
||||
updated={false}
|
||||
updateInProgress={false}
|
||||
availability={{
|
||||
calendar: {
|
||||
// '2018-12': {
|
||||
// bookings: [],
|
||||
// exceptions: [],
|
||||
// fetchExceptionsError: null,
|
||||
// fetchExceptionsInProgress: false,
|
||||
// fetchBookingsError: null,
|
||||
// fetchBookingsInProgress: false,
|
||||
// },
|
||||
},
|
||||
onCreateAvailabilityException: noop,
|
||||
onDeleteAvailabilityException: noop,
|
||||
onFetchAvailabilityExceptions: noop,
|
||||
onFetchBookings: noop,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
:root {
|
||||
--ManageAvailabilityCalendar_gridColor: #e0e0e0;
|
||||
--ManageAvailabilityCalendar_availableColor: #ffffff;
|
||||
--ManageAvailabilityCalendar_availableColorHover: #fafafa;
|
||||
--ManageAvailabilityCalendar_blockedColor: #ebebeb;
|
||||
--ManageAvailabilityCalendar_blockedColorHover: #e6e6e6;
|
||||
--ManageAvailabilityCalendar_reservedColor: #e6fff0;
|
||||
--ManageAvailabilityCalendar_reservedColorHover: #e1faeb;
|
||||
--ManageAvailabilityCalendar_failedColor: #fff2f2;
|
||||
}
|
||||
|
||||
.root {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
overflow-x: hidden;
|
||||
z-index: 0;
|
||||
|
||||
& :global(.CalendarMonth_caption) {
|
||||
@apply --marketplaceH3FontStyles;
|
||||
font-weight: var(--fontWeightMedium);
|
||||
text-align: left;
|
||||
padding-top: 18px;
|
||||
padding-bottom: 55px;
|
||||
|
||||
margin-left: 97px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
& strong {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
}
|
||||
|
||||
& :global(.DayPicker) {
|
||||
margin: 0 auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
& :global(.DayPicker__horizontal) {
|
||||
background-color: transparent;
|
||||
margin-left: -18px;
|
||||
}
|
||||
|
||||
& :global(.DayPicker_weekHeader) {
|
||||
top: 65px;
|
||||
|
||||
& small {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
}
|
||||
}
|
||||
& :global(.DayPicker_weekHeader_li) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
& :global(.DayPickerNavigation__horizontal) {
|
||||
width: 80px;
|
||||
margin-left: 18px;
|
||||
position: relative;
|
||||
|
||||
& :first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
& :last-child {
|
||||
/* The navigation arrows have 9px padding. Add -9px margin to
|
||||
align the arrows with the calendar */
|
||||
left: 35px;
|
||||
right: unset;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& :global(.DayPickerNavigation_button__horizontal) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: solid 1px var(--matterColorNegative);
|
||||
background-color: var(--matterColorBright);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ManageAvailabilityCalendar_availableColorHover);
|
||||
|
||||
& svg {
|
||||
fill: var(--matterColorDark);
|
||||
}
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: var(--ManageAvailabilityCalendar_availableColorHover);
|
||||
& svg {
|
||||
fill: var(--matterColorDark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& :global(.CalendarMonthGrid) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
& :global(.CalendarMonth) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
& :global(.CalendarMonth_table) {
|
||||
border: 1px solid var(--ManageAvailabilityCalendar_gridColor);
|
||||
}
|
||||
|
||||
& :global(.CalendarDay__default) {
|
||||
border: 0;
|
||||
background-color: var(--ManageAvailabilityCalendar_gridColor);
|
||||
|
||||
&:hover {
|
||||
border: 0;
|
||||
background-color: var(--ManageAvailabilityCalendar_gridColor);
|
||||
}
|
||||
}
|
||||
|
||||
& :global(.CalendarDay__selected) {
|
||||
color: var(--matterColor);
|
||||
}
|
||||
|
||||
& :global(.DayPickerKeyboardShortcuts_show__bottomRight) {
|
||||
right: -20px;
|
||||
right: 19px;
|
||||
bottom: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.dayWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.day {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
padding: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ManageAvailabilityCalendar_availableColorHover);
|
||||
}
|
||||
}
|
||||
|
||||
.dayNumber {
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.default {
|
||||
composes: day;
|
||||
background-color: var(--ManageAvailabilityCalendar_availableColor);
|
||||
}
|
||||
|
||||
.outsideRange {
|
||||
background-color: var(--ManageAvailabilityCalendar_blockedColor);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ManageAvailabilityCalendar_blockedColorHover);
|
||||
}
|
||||
|
||||
& .dayNumber {
|
||||
text-decoration: line-through;
|
||||
color: lightgrey;
|
||||
}
|
||||
}
|
||||
|
||||
.today {
|
||||
background-color: var(--ManageAvailabilityCalendar_availableColor);
|
||||
|
||||
& .dayNumber {
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
/* underline */
|
||||
&:after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
right: 0;
|
||||
bottom: -4px;
|
||||
left: 0;
|
||||
margin: 0 auto;
|
||||
background-color: var(--matterColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blocked {
|
||||
background-color: var(--ManageAvailabilityCalendar_blockedColor);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ManageAvailabilityCalendar_blockedColorHover);
|
||||
}
|
||||
}
|
||||
|
||||
.reserved {
|
||||
background-color: var(--ManageAvailabilityCalendar_reservedColor);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ManageAvailabilityCalendar_reservedColorHover);
|
||||
}
|
||||
}
|
||||
|
||||
.inProgress {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: var(--matterColor);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.exceptionError {
|
||||
opacity: 1;
|
||||
|
||||
/* Animation */
|
||||
animation-name: errored;
|
||||
animation-duration: 800ms;
|
||||
animation-iteration-count: 1;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
|
||||
@keyframes errored {
|
||||
30%,
|
||||
70% {
|
||||
background-color: var(--ManageAvailabilityCalendar_failedColor);
|
||||
}
|
||||
}
|
||||
|
||||
.monthElement {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.monthString {
|
||||
font-weight: var(--fontWeightSemiBold);
|
||||
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.monthInProgress {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 7px;
|
||||
margin-left: 8px;
|
||||
|
||||
stroke: var(--marketplaceColor);
|
||||
stroke-width: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
color: var(--failColor);
|
||||
margin: 6px 0 24px 0;
|
||||
}
|
||||
|
||||
.rootNextMonthIcon,
|
||||
.rootPreviousMonthIcon {
|
||||
stroke: var(--matterColor);
|
||||
fill: var(--matterColor);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legendRow {
|
||||
display: flex;
|
||||
flex-grow: row;
|
||||
line-height: 24px;
|
||||
margin-right: 18px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.legendColor {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.legendAvailableColor {
|
||||
composes: legendColor;
|
||||
background-color: var(--matterColorLight);
|
||||
border: solid 2px var(--matterColorNegative);
|
||||
}
|
||||
.legendReservedColor {
|
||||
composes: legendColor;
|
||||
background-color: var(--ManageAvailabilityCalendar_reservedColor);
|
||||
}
|
||||
.legendBlockedColor {
|
||||
composes: legendColor;
|
||||
background-color: var(--ManageAvailabilityCalendar_blockedColorHover);
|
||||
}
|
||||
|
||||
.legendText {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 550px) {
|
||||
/* day aka table cell content should have bigger paddings when there's more space */
|
||||
.day {
|
||||
padding: 16px;
|
||||
}
|
||||
.dayNumber {
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard break points */
|
||||
@media (--viewportMedium) {
|
||||
.root {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
& :global(.CalendarMonth_caption) {
|
||||
font-size: 24px;
|
||||
padding-bottom: 62px;
|
||||
}
|
||||
& :global(.DayPicker_weekHeader) {
|
||||
top: 67px;
|
||||
}
|
||||
|
||||
& :global(.DayPickerNavigation__horizontal) {
|
||||
& :last-child {
|
||||
/* The navigation arrows have 9px padding. Add -9px margin to
|
||||
align the arrows with the calendar */
|
||||
left: 39px;
|
||||
}
|
||||
}
|
||||
|
||||
& :global(.DayPickerNavigation_button__horizontal) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.inProgress {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.monthInProgress {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.error {
|
||||
margin: 8px 0 24px 0;
|
||||
}
|
||||
|
||||
.legendRow {
|
||||
margin-right: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.legendText {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--viewportLarge) {
|
||||
.root {
|
||||
& :global(.DayPickerNavigation_button__horizontal) {
|
||||
background-color: var(--matterColorLight);
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--viewportXLarge) {
|
||||
.root {
|
||||
& :global(.CalendarMonth_caption) {
|
||||
margin-left: 108px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,499 @@
|
|||
import React, { Component } from 'react';
|
||||
import { func, object, shape, string } from 'prop-types';
|
||||
import {
|
||||
DayPickerSingleDateController,
|
||||
isSameDay,
|
||||
isInclusivelyBeforeDay,
|
||||
isInclusivelyAfterDay,
|
||||
} from 'react-dates';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import memoize from 'lodash/memoize';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
ensureBooking,
|
||||
ensureAvailabilityException,
|
||||
ensureDayAvailabilityPlan,
|
||||
} from '../../util/data';
|
||||
import { DAYS_OF_WEEK } from '../../util/types';
|
||||
import { monthIdString } from '../../util/dates';
|
||||
import { IconArrowHead, IconSpinner } from '../../components';
|
||||
|
||||
import css from './ManageAvailabilityCalendar.css';
|
||||
|
||||
// Constants
|
||||
|
||||
const HORIZONTAL_ORIENTATION = 'horizontal';
|
||||
const MAX_AVAILABILITY_EXCEPTIONS_RANGE = 180;
|
||||
const TODAY_MOMENT = moment().startOf('day');
|
||||
const END_OF_RANGE_MOMENT = TODAY_MOMENT.clone()
|
||||
.add(MAX_AVAILABILITY_EXCEPTIONS_RANGE - 1, 'days')
|
||||
.startOf('day');
|
||||
|
||||
// Constants for calculating day width (aka table cell dimensions)
|
||||
const TABLE_BORDER = 2;
|
||||
const TABLE_COLUMNS = 7;
|
||||
const MIN_CONTENT_WIDTH = 272;
|
||||
const MIN_CELL_WIDTH = Math.floor(MIN_CONTENT_WIDTH / TABLE_COLUMNS); // 38
|
||||
const MAX_CONTENT_WIDTH_DESKTOP = 756;
|
||||
const MAX_CELL_WIDTH_DESKTOP = Math.floor(MAX_CONTENT_WIDTH_DESKTOP / TABLE_COLUMNS); // 108
|
||||
const VIEWPORT_LARGE = 1024;
|
||||
|
||||
// Helper functions
|
||||
|
||||
// Calculate the width for a calendar day (table cell)
|
||||
const dayWidth = (wrapperWidth, windowWith) => {
|
||||
if (windowWith >= VIEWPORT_LARGE) {
|
||||
// NOTE: viewportLarge has a layout with sidebar.
|
||||
// In that layout 30% is reserved for paddings and 282 px goes to sidebar and gutter.
|
||||
const width = windowWith * 0.7 - 282;
|
||||
return width > MAX_CONTENT_WIDTH_DESKTOP
|
||||
? MAX_CELL_WIDTH_DESKTOP
|
||||
: Math.floor((width - TABLE_BORDER) / TABLE_COLUMNS);
|
||||
} else {
|
||||
return wrapperWidth > MIN_CONTENT_WIDTH
|
||||
? Math.floor((wrapperWidth - TABLE_BORDER) / TABLE_COLUMNS)
|
||||
: MIN_CELL_WIDTH;
|
||||
}
|
||||
};
|
||||
|
||||
// Get a function that returns the start of the previous month
|
||||
const prevMonthFn = currentMoment =>
|
||||
currentMoment
|
||||
.clone()
|
||||
.subtract(1, 'months')
|
||||
.startOf('month');
|
||||
|
||||
// Get a function that returns the start of the next month
|
||||
const nextMonthFn = currentMoment =>
|
||||
currentMoment
|
||||
.clone()
|
||||
.add(1, 'months')
|
||||
.startOf('month');
|
||||
|
||||
// Get the start and end Dates in UTC
|
||||
const dateStartAndEndInUTC = date => {
|
||||
const start = moment(date)
|
||||
.utc()
|
||||
.startOf('day')
|
||||
.toDate();
|
||||
const end = moment(date)
|
||||
.utc()
|
||||
.add(1, 'days')
|
||||
.startOf('day')
|
||||
.toDate();
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const momentToUTCDate = dateMoment =>
|
||||
dateMoment
|
||||
.clone()
|
||||
.utc()
|
||||
.add(dateMoment.utcOffset(), 'minutes')
|
||||
.toDate();
|
||||
|
||||
// outside range -><- today ... today+MAX_AVAILABILITY_EXCEPTIONS_RANGE -1 -><- outside range
|
||||
const isDateOutsideRange = date => {
|
||||
return (
|
||||
!isInclusivelyAfterDay(date, TODAY_MOMENT) || !isInclusivelyBeforeDay(date, END_OF_RANGE_MOMENT)
|
||||
);
|
||||
};
|
||||
const isOutsideRange = memoize(isDateOutsideRange);
|
||||
|
||||
const isMonthInRange = monthMoment => {
|
||||
const isAfterThisMonth = monthMoment.isSameOrAfter(TODAY_MOMENT, 'month');
|
||||
const isBeforeEndOfRange = monthMoment.isSameOrBefore(END_OF_RANGE_MOMENT, 'month');
|
||||
return isAfterThisMonth && isBeforeEndOfRange;
|
||||
};
|
||||
|
||||
const isPast = date => !isInclusivelyAfterDay(date, TODAY_MOMENT);
|
||||
const isAfterEndOfRange = date => !isInclusivelyBeforeDay(date, END_OF_RANGE_MOMENT);
|
||||
|
||||
const isBooked = (bookings, day) => {
|
||||
return !!bookings.find(b => {
|
||||
const booking = ensureBooking(b);
|
||||
const start = booking.attributes.start;
|
||||
const end = booking.attributes.end;
|
||||
const dayInUTC = day.clone().utc();
|
||||
|
||||
// '[)' means that the range start is inclusive and range end is exclusive
|
||||
return dayInUTC.isBetween(moment(start).utc(), moment(end).utc(), null, '[)');
|
||||
});
|
||||
};
|
||||
|
||||
const findException = (exceptions, day) => {
|
||||
return exceptions.find(exception => {
|
||||
const availabilityException = ensureAvailabilityException(exception.availabilityException);
|
||||
const start = availabilityException.attributes.start;
|
||||
const dayInUTC = day.clone().utc();
|
||||
return isSameDay(moment(start).utc(), dayInUTC);
|
||||
});
|
||||
};
|
||||
|
||||
const isBlocked = (availabilityPlan, exception, date) => {
|
||||
const planEntries = ensureDayAvailabilityPlan(availabilityPlan).entries;
|
||||
const seatsFromPlan = planEntries.find(
|
||||
weekDayEntry => weekDayEntry.dayOfWeek === DAYS_OF_WEEK[date.isoWeekday() - 1]
|
||||
).seats;
|
||||
|
||||
const seatsFromException =
|
||||
exception && ensureAvailabilityException(exception.availabilityException).attributes.seats;
|
||||
|
||||
const seats = exception ? seatsFromException : seatsFromPlan;
|
||||
return seats === 0;
|
||||
};
|
||||
|
||||
const dateModifiers = (availabilityPlan, exceptions, bookings, date) => {
|
||||
const exception = findException(exceptions, date);
|
||||
|
||||
return {
|
||||
isOutsideRange: isOutsideRange(date),
|
||||
isSameDay: isSameDay(date, TODAY_MOMENT),
|
||||
isBlocked: isBlocked(availabilityPlan, exception, date),
|
||||
isBooked: isBooked(bookings, date),
|
||||
isInProgress: exception && exception.inProgress,
|
||||
isFailed: exception && exception.error,
|
||||
};
|
||||
};
|
||||
|
||||
const renderDayContents = (calendar, availabilityPlan) => date => {
|
||||
const { exceptions = [], bookings = [] } = calendar[monthIdString(date)] || {};
|
||||
const { isOutsideRange, isSameDay, isBlocked, isBooked, isInProgress, isFailed } = dateModifiers(
|
||||
availabilityPlan,
|
||||
exceptions,
|
||||
bookings,
|
||||
date
|
||||
);
|
||||
|
||||
const dayClasses = classNames(css.default, {
|
||||
[css.outsideRange]: isOutsideRange,
|
||||
[css.today]: isSameDay,
|
||||
[css.blocked]: isBlocked,
|
||||
[css.reserved]: isBooked,
|
||||
[css.exceptionError]: isFailed,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={css.dayWrapper}>
|
||||
<span className={dayClasses}>
|
||||
{isInProgress ? (
|
||||
<IconSpinner rootClassName={css.inProgress} />
|
||||
) : (
|
||||
<span className={css.dayNumber}>{date.format('D')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const makeDraftException = (exceptions, start, end, seats) => {
|
||||
const draft = ensureAvailabilityException({ attributes: { start, end, seats } });
|
||||
return { availabilityException: draft };
|
||||
};
|
||||
|
||||
////////////////////////////////
|
||||
// ManageAvailabilityCalendar //
|
||||
////////////////////////////////
|
||||
class ManageAvailabilityCalendar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// DOM refs
|
||||
this.dayPickerWrapper = null;
|
||||
this.dayPicker = null;
|
||||
|
||||
this.state = {
|
||||
currentMonth: moment().startOf('month'),
|
||||
focused: true,
|
||||
date: null,
|
||||
};
|
||||
|
||||
this.fetchMonthData = this.fetchMonthData.bind(this);
|
||||
this.onDayAvailabilityChange = this.onDayAvailabilityChange.bind(this);
|
||||
this.onDateChange = this.onDateChange.bind(this);
|
||||
this.onFocusChange = this.onFocusChange.bind(this);
|
||||
this.onMonthClick = this.onMonthClick.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Fetch month data if user have navigated to availability tab in EditListingWizard
|
||||
this.fetchMonthData(this.state.currentMonth);
|
||||
// Fetch next month too.
|
||||
this.fetchMonthData(nextMonthFn(this.state.currentMonth));
|
||||
}
|
||||
|
||||
fetchMonthData(monthMoment) {
|
||||
const { availability, listingId } = this.props;
|
||||
|
||||
// Don't fetch exceptions for past months or too far in the future
|
||||
if (isMonthInRange(monthMoment)) {
|
||||
// Use "today", if the first day of given month is in the past
|
||||
const startMoment = isPast(monthMoment) ? TODAY_MOMENT : monthMoment;
|
||||
const start = momentToUTCDate(startMoment);
|
||||
|
||||
// Use END_OF_RANGE_MOMENT, if the first day of the next month is too far in the future
|
||||
const nextMonthMoment = nextMonthFn(monthMoment);
|
||||
const endMoment = isAfterEndOfRange(nextMonthMoment)
|
||||
? END_OF_RANGE_MOMENT.clone().add(1, 'days')
|
||||
: nextMonthMoment;
|
||||
const end = momentToUTCDate(endMoment);
|
||||
|
||||
// Fetch AvailabilityExceptions for this month
|
||||
availability.onFetchAvailabilityExceptions({ listingId, start, end });
|
||||
|
||||
// Fetch Bookings for this month (if they are in pending or accepted state)
|
||||
const state = ['pending', 'accepted'].join(',');
|
||||
availability.onFetchBookings({ listingId, start, end, state });
|
||||
}
|
||||
}
|
||||
|
||||
onDayAvailabilityChange(date, seats, exceptions) {
|
||||
const { availabilityPlan, listingId } = this.props;
|
||||
const { start, end } = dateStartAndEndInUTC(date);
|
||||
|
||||
const planEntries = ensureDayAvailabilityPlan(availabilityPlan).entries;
|
||||
const seatsFromPlan = planEntries.find(
|
||||
weekDayEntry => weekDayEntry.dayOfWeek === DAYS_OF_WEEK[date.isoWeekday() - 1]
|
||||
).seats;
|
||||
|
||||
const currentException = findException(exceptions, date);
|
||||
const draftException = makeDraftException(exceptions, start, end, seatsFromPlan);
|
||||
const exception = currentException || draftException;
|
||||
const hasAvailabilityException = currentException && currentException.availabilityException.id;
|
||||
|
||||
if (hasAvailabilityException) {
|
||||
const id = currentException.availabilityException.id;
|
||||
const isResetToPlanSeats = seatsFromPlan === seats;
|
||||
|
||||
if (isResetToPlanSeats) {
|
||||
// Delete the exception, if the exception is redundant
|
||||
// (it has the same content as what user has in the plan).
|
||||
this.props.availability.onDeleteAvailabilityException({
|
||||
id,
|
||||
currentException: exception,
|
||||
seats: seatsFromPlan,
|
||||
});
|
||||
} else {
|
||||
// If availability exception exists, delete it first and then create a new one.
|
||||
// NOTE: currently, API does not support update (only deleting and creating)
|
||||
this.props.availability
|
||||
.onDeleteAvailabilityException({ id, currentException: exception, seats: seatsFromPlan })
|
||||
.then(r => {
|
||||
const params = { listingId, start, end, seats, currentException: exception };
|
||||
this.props.availability.onCreateAvailabilityException(params);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If there is no existing AvailabilityExceptions, just create a new one
|
||||
const params = { listingId, start, end, seats, currentException: exception };
|
||||
this.props.availability.onCreateAvailabilityException(params);
|
||||
}
|
||||
}
|
||||
|
||||
onDateChange(date) {
|
||||
this.setState({ date });
|
||||
|
||||
const { availabilityPlan, availability } = this.props;
|
||||
const calendar = availability.calendar;
|
||||
const { exceptions = [], bookings = [] } = calendar[monthIdString(date)] || {};
|
||||
const { isPast, isBlocked, isBooked, isInProgress } = dateModifiers(
|
||||
availabilityPlan,
|
||||
exceptions,
|
||||
bookings,
|
||||
date
|
||||
);
|
||||
|
||||
if (isBooked || isPast || isInProgress) {
|
||||
// Cannot allow or block a reserved or a past date or inProgress
|
||||
return;
|
||||
} else if (isBlocked) {
|
||||
// Unblock the date (seats = 1)
|
||||
this.onDayAvailabilityChange(date, 1, exceptions);
|
||||
} else {
|
||||
// Block the date (seats = 0)
|
||||
this.onDayAvailabilityChange(date, 0, exceptions);
|
||||
}
|
||||
}
|
||||
|
||||
onFocusChange() {
|
||||
// Force the state.focused to always be truthy so that date is always selectable
|
||||
this.setState({ focused: true });
|
||||
}
|
||||
|
||||
onMonthClick(monthFn) {
|
||||
const onMonthChanged = this.props.onMonthChanged;
|
||||
this.setState(
|
||||
prevState => ({ currentMonth: monthFn(prevState.currentMonth) }),
|
||||
() => {
|
||||
// Callback function after month has been updated.
|
||||
// react-dates component has next and previous months ready (but inivisible).
|
||||
// we try to populate those invisible months before user advances there.
|
||||
this.fetchMonthData(monthFn(this.state.currentMonth));
|
||||
|
||||
// If previous fetch for month data failed, try again.
|
||||
const monthId = monthIdString(this.state.currentMonth);
|
||||
const currentMonthData = this.props.availability.calendar[monthId];
|
||||
const { fetchExceptionsError, fetchBookingsError } = currentMonthData || {};
|
||||
if (currentMonthData && (fetchExceptionsError || fetchBookingsError)) {
|
||||
this.fetchMonthData(this.state.currentMonth);
|
||||
}
|
||||
|
||||
// Call onMonthChanged function if it has been passed in among props.
|
||||
if (onMonthChanged) {
|
||||
onMonthChanged(monthIdString(this.state.currentMonth));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
rootClassName,
|
||||
listingId,
|
||||
availability,
|
||||
availabilityPlan,
|
||||
onMonthChanged,
|
||||
monthFormat,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { focused, date, currentMonth } = this.state;
|
||||
const { clientWidth: width } = this.dayPickerWrapper || { clientWidth: 0 };
|
||||
const hasWindow = typeof window !== 'undefined';
|
||||
const windowWidth = hasWindow ? window.innerWidth : 0;
|
||||
|
||||
const daySize = dayWidth(width, windowWidth);
|
||||
const calendarGridWidth = daySize * TABLE_COLUMNS + TABLE_BORDER;
|
||||
|
||||
const calendar = availability.calendar;
|
||||
const currentMonthData = calendar[monthIdString(currentMonth)];
|
||||
const {
|
||||
fetchExceptionsInProgress,
|
||||
fetchBookingsInProgress,
|
||||
fetchExceptionsError,
|
||||
fetchBookingsError,
|
||||
} = currentMonthData || {};
|
||||
const isMonthDataFetched =
|
||||
!isMonthInRange(currentMonth) ||
|
||||
(!!currentMonthData && !fetchExceptionsInProgress && !fetchBookingsInProgress);
|
||||
|
||||
const monthName = currentMonth.format('MMMM');
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
ref={c => {
|
||||
this.dayPickerWrapper = c;
|
||||
}}
|
||||
>
|
||||
{width > 0 ? (
|
||||
<div style={{ width: `${calendarGridWidth}px` }}>
|
||||
<DayPickerSingleDateController
|
||||
{...rest}
|
||||
ref={c => {
|
||||
this.dayPicker = c;
|
||||
}}
|
||||
numberOfMonths={1}
|
||||
navPrev={<IconArrowHead direction="left" />}
|
||||
navNext={<IconArrowHead direction="right" />}
|
||||
weekDayFormat="ddd"
|
||||
daySize={daySize}
|
||||
renderDayContents={renderDayContents(calendar, availabilityPlan)}
|
||||
focused={focused}
|
||||
date={date}
|
||||
onDateChange={this.onDateChange}
|
||||
onFocusChange={this.onFocusChange}
|
||||
onPrevMonthClick={() => this.onMonthClick(prevMonthFn)}
|
||||
onNextMonthClick={() => this.onMonthClick(nextMonthFn)}
|
||||
hideKeyboardShortcutsPanel
|
||||
horizontalMonthPadding={9}
|
||||
renderMonthElement={({ month }) => (
|
||||
<div className={css.monthElement}>
|
||||
<span className={css.monthString}>{month.format(monthFormat)}</span>
|
||||
{!isMonthDataFetched ? <IconSpinner rootClassName={css.monthInProgress} /> : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={css.legend} style={{ width: `${calendarGridWidth}px` }}>
|
||||
<div className={css.legendRow}>
|
||||
<span className={css.legendAvailableColor} />
|
||||
<span className={css.legendText}>
|
||||
<FormattedMessage id="EditListingAvailabilityForm.availableDay" />
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.legendRow}>
|
||||
<span className={css.legendBlockedColor} />
|
||||
<span className={css.legendText}>
|
||||
<FormattedMessage id="EditListingAvailabilityForm.blockedDay" />
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.legendRow}>
|
||||
<span className={css.legendReservedColor} />
|
||||
<span className={css.legendText}>
|
||||
<FormattedMessage id="EditListingAvailabilityForm.bookedDay" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{fetchExceptionsError && fetchBookingsError ? (
|
||||
<p className={css.error}>
|
||||
<FormattedMessage
|
||||
id="EditListingAvailabilityForm.fetchMonthDataFailed"
|
||||
values={{ month: monthName }}
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ManageAvailabilityCalendar.defaultProps = {
|
||||
className: null,
|
||||
rootClassName: null,
|
||||
|
||||
// day presentation and interaction related props
|
||||
renderCalendarDay: undefined,
|
||||
renderDayContents: null,
|
||||
isDayBlocked: () => false,
|
||||
isOutsideRange,
|
||||
isDayHighlighted: () => false,
|
||||
enableOutsideDays: true,
|
||||
|
||||
// calendar presentation and interaction related props
|
||||
orientation: HORIZONTAL_ORIENTATION,
|
||||
withPortal: false,
|
||||
initialVisibleMonth: null,
|
||||
numberOfMonths: 2,
|
||||
onOutsideClick() {},
|
||||
keepOpenOnDateSelect: false,
|
||||
renderCalendarInfo: null,
|
||||
isRTL: false,
|
||||
|
||||
// navigation related props
|
||||
navPrev: null,
|
||||
navNext: null,
|
||||
onPrevMonthClick() {},
|
||||
onNextMonthClick() {},
|
||||
|
||||
// internationalization
|
||||
monthFormat: 'MMMM YYYY',
|
||||
onMonthChanged: null,
|
||||
};
|
||||
|
||||
ManageAvailabilityCalendar.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
availability: shape({
|
||||
calendar: object.isRequired,
|
||||
onFetchAvailabilityExceptions: func.isRequired,
|
||||
onFetchBookings: func.isRequired,
|
||||
onDeleteAvailabilityException: func.isRequired,
|
||||
onCreateAvailabilityException: func.isRequired,
|
||||
}).isRequired,
|
||||
onMonthChanged: func,
|
||||
};
|
||||
|
||||
export default ManageAvailabilityCalendar;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`EditListingAvailabilityForm matches snapshot 1`] = `
|
||||
<ReactFinalForm
|
||||
availability={
|
||||
Object {
|
||||
"calendar": Object {},
|
||||
"onCreateAvailabilityException": [Function],
|
||||
"onDeleteAvailabilityException": [Function],
|
||||
"onFetchAvailabilityExceptions": [Function],
|
||||
"onFetchBookings": [Function],
|
||||
}
|
||||
}
|
||||
dispatch={[Function]}
|
||||
intl={
|
||||
Object {
|
||||
"formatDate": [Function],
|
||||
"formatHTMLMessage": [Function],
|
||||
"formatMessage": [Function],
|
||||
"formatNumber": [Function],
|
||||
"formatPlural": [Function],
|
||||
"formatRelative": [Function],
|
||||
"formatTime": [Function],
|
||||
"now": [Function],
|
||||
}
|
||||
}
|
||||
onSubmit={[Function]}
|
||||
render={[Function]}
|
||||
saveActionMsg="Save rules"
|
||||
updateError={null}
|
||||
updateInProgress={false}
|
||||
updated={false}
|
||||
/>
|
||||
`;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export { default as BookingDatesForm } from './BookingDatesForm/BookingDatesForm';
|
||||
export { default as ContactDetailsForm } from './ContactDetailsForm/ContactDetailsForm';
|
||||
export { default as EditListingAvailabilityForm } from './EditListingAvailabilityForm/EditListingAvailabilityForm';
|
||||
export { default as EditListingDescriptionForm } from './EditListingDescriptionForm/EditListingDescriptionForm';
|
||||
export { default as EditListingFeaturesForm } from './EditListingFeaturesForm/EditListingFeaturesForm';
|
||||
export { default as EditListingLocationForm } from './EditListingLocationForm/EditListingLocationForm';
|
||||
|
|
|
|||
|
|
@ -128,6 +128,13 @@
|
|||
"DateInput.closeDatePicker": "Close",
|
||||
"DateInput.defaultPlaceholder": "Date input",
|
||||
"DateInput.screenReaderInputMessage": "Date input",
|
||||
"EditListingAvailabilityForm.fetchMonthDataFailed": "Oops, couldn't load data for {month}, please try again.",
|
||||
"EditListingAvailabilityForm.availableDay": "Available",
|
||||
"EditListingAvailabilityForm.blockedDay": "Not available",
|
||||
"EditListingAvailabilityForm.bookedDay": "Booked",
|
||||
"EditListingAvailabilityForm.updateFailed": "Failed to update listing. Please try again.",
|
||||
"EditListingAvailabilityPanel.title": "Edit the availability of {listingTitle}",
|
||||
"EditListingAvailabilityPanel.createListingTitle": "When is it available?",
|
||||
"EditListingDescriptionForm.categoryLabel": "Sauna type",
|
||||
"EditListingDescriptionForm.categoryPlaceholder": "Choose the type of your sauna…",
|
||||
"EditListingDescriptionForm.categoryRequired": "You need to select a category for your sauna.",
|
||||
|
|
@ -196,17 +203,20 @@
|
|||
"EditListingWizard.saveEditDescription": "Save description",
|
||||
"EditListingWizard.saveEditFeatures": "Save amenities",
|
||||
"EditListingWizard.saveEditLocation": "Save location",
|
||||
"EditListingWizard.saveEditAvailability": "Save availability",
|
||||
"EditListingWizard.saveEditPhotos": "Save photos",
|
||||
"EditListingWizard.saveEditPolicies": "Save rules",
|
||||
"EditListingWizard.saveEditPricing": "Save pricing",
|
||||
"EditListingWizard.saveNewDescription": "Next: Amenities",
|
||||
"EditListingWizard.saveNewFeatures": "Next: Rules",
|
||||
"EditListingWizard.saveNewLocation": "Next: Pricing",
|
||||
"EditListingWizard.saveNewAvailability": "Next: Photos",
|
||||
"EditListingWizard.saveNewPhotos": "Publish listing",
|
||||
"EditListingWizard.saveNewPolicies": "Next: Location",
|
||||
"EditListingWizard.saveNewPricing": "Next: Photos",
|
||||
"EditListingWizard.saveNewPricing": "Next: Availability",
|
||||
"EditListingWizard.tabLabelDescription": "Description",
|
||||
"EditListingWizard.tabLabelFeatures": "Amenities",
|
||||
"EditListingWizard.tabLabelAvailability": "Availability",
|
||||
"EditListingWizard.tabLabelLocation": "Location",
|
||||
"EditListingWizard.tabLabelPhotos": "Photos",
|
||||
"EditListingWizard.tabLabelPolicy": "Sauna rules",
|
||||
|
|
|
|||
|
|
@ -128,6 +128,13 @@
|
|||
"DateInput.closeDatePicker": "Fermer",
|
||||
"DateInput.defaultPlaceholder": "Date",
|
||||
"DateInput.screenReaderInputMessage": "Date",
|
||||
"EditListingAvailabilityForm.availableDay": "Available",
|
||||
"EditListingAvailabilityForm.blockedDay": "Not available",
|
||||
"EditListingAvailabilityForm.bookedDay": "Booked",
|
||||
"EditListingAvailabilityForm.fetchMonthDataFailed": "Oops, couldn't load data for {month}, please try again.",
|
||||
"EditListingAvailabilityForm.updateFailed": "Failed to update listing. Please try again.",
|
||||
"EditListingAvailabilityPanel.createListingTitle": "When is it available?",
|
||||
"EditListingAvailabilityPanel.title": "Edit the availability of {listingTitle}",
|
||||
"EditListingDescriptionForm.categoryLabel": "Type de sauna",
|
||||
"EditListingDescriptionForm.categoryPlaceholder": "Choisissez le type de votre sauna…",
|
||||
"EditListingDescriptionForm.categoryRequired": "Vous devez choisir une catégorie pour votre sauna.",
|
||||
|
|
@ -193,18 +200,21 @@
|
|||
"EditListingPricingPanel.createListingTitle": "À combien souhaitez-vous louer votre sauna ?",
|
||||
"EditListingPricingPanel.listingPriceCurrencyInvalid": "La devise de l'annonce est différente de la devise de la place de marché. Vous ne pouvez pas modifier le prix.",
|
||||
"EditListingPricingPanel.title": "Modifier le prix de {listingTitle}",
|
||||
"EditListingWizard.saveEditAvailability": "Save availability",
|
||||
"EditListingWizard.saveEditDescription": "Enregistrer les détails",
|
||||
"EditListingWizard.saveEditFeatures": "Enregistrer les équipements",
|
||||
"EditListingWizard.saveEditLocation": "Enregistrer l'adresse",
|
||||
"EditListingWizard.saveEditPhotos": "Enregistrer les images",
|
||||
"EditListingWizard.saveEditPolicies": "Enregistrer le règlement",
|
||||
"EditListingWizard.saveEditPricing": "Enregistrer le prix",
|
||||
"EditListingWizard.saveNewAvailability": "Next: Photos",
|
||||
"EditListingWizard.saveNewDescription": "Passer à l'adresse",
|
||||
"EditListingWizard.saveNewFeatures": "Passer aux règles",
|
||||
"EditListingWizard.saveNewLocation": "Passer au prix",
|
||||
"EditListingWizard.saveNewPhotos": "Publier l'annonce",
|
||||
"EditListingWizard.saveNewPolicies": "Passer à l'adresse",
|
||||
"EditListingWizard.saveNewPricing": "Passer aux images",
|
||||
"EditListingWizard.tabLabelAvailability": "Availability",
|
||||
"EditListingWizard.tabLabelDescription": "Description",
|
||||
"EditListingWizard.tabLabelFeatures": "Équipements",
|
||||
"EditListingWizard.tabLabelLocation": "Adresse",
|
||||
|
|
@ -505,12 +515,12 @@
|
|||
"PayoutDetailsForm.lastNamePlaceholder": "Nom",
|
||||
"PayoutDetailsForm.lastNameRequired": "Ce champ est requis.",
|
||||
"PayoutDetailsForm.personalDetailsTitle": "Détails",
|
||||
"PayoutDetailsForm.personalIdNumberTitle": "Numéro d'identité",
|
||||
"PayoutDetailsForm.personalIdNumberLabel.HK": "Hong Kong Identity Card Number (HKID)",
|
||||
"PayoutDetailsForm.personalIdNumberLabel.US": "Quatre derniers chiffres du numéro de sécurité sociale (SSN)",
|
||||
"PayoutDetailsForm.personalIdNumberPlaceholder.HK": "XY123456",
|
||||
"PayoutDetailsForm.personalIdNumberPlaceholder.US": "1234",
|
||||
"PayoutDetailsForm.personalIdNumberRequired": "Ce champ est requis",
|
||||
"PayoutDetailsForm.personalIdNumberTitle": "Numéro d'identité",
|
||||
"PayoutDetailsForm.personalIdNumberValid": "Valeur incorrecte",
|
||||
"PayoutDetailsForm.postalCodeLabel": "Code postal",
|
||||
"PayoutDetailsForm.postalCodePlaceholder": "00100",
|
||||
|
|
@ -537,8 +547,8 @@
|
|||
"PayoutPreferencesPage.title": "Paiements",
|
||||
"PriceFilter.clear": "Effacer",
|
||||
"PriceFilter.label": "Prix",
|
||||
"PriceFilter.labelSelectedPlain": "Prix : {minPrice} - {maxPrice}",
|
||||
"PriceFilter.labelSelectedButton": "{minPrice} - {maxPrice}",
|
||||
"PriceFilter.labelSelectedPlain": "Prix : {minPrice} - {maxPrice}",
|
||||
"PriceFilterForm.cancel": "Annuler",
|
||||
"PriceFilterForm.clear": "Effacer",
|
||||
"PriceFilterForm.label": "Gamme de prix :",
|
||||
|
|
@ -805,4 +815,4 @@
|
|||
"UserCard.heading": "Bonjour, je suis {name}.",
|
||||
"UserCard.showFullBioLink": "plus",
|
||||
"UserCard.viewProfileLink": "Voir le profil"
|
||||
}
|
||||
}
|
||||
|
|
@ -44,9 +44,11 @@ export const withViewport = Component => {
|
|||
componentDidMount() {
|
||||
this.setViewport();
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
window.addEventListener('orientationchange', this.handleWindowResize);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
window.removeEventListener('orientationchange', this.handleWindowResize);
|
||||
}
|
||||
handleWindowResize() {
|
||||
this.setViewport();
|
||||
|
|
|
|||
|
|
@ -217,6 +217,26 @@ export const ensureTimeSlot = timeSlot => {
|
|||
return { ...empty, ...timeSlot };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create shell objects to ensure that attributes etc. exists.
|
||||
*
|
||||
* @param {Object} availability exception entity object, which is to be ensured against null values
|
||||
*/
|
||||
export const ensureDayAvailabilityPlan = availabilityPlan => {
|
||||
const empty = { type: 'availability-plan/day', entries: [] };
|
||||
return { ...empty, ...availabilityPlan };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create shell objects to ensure that attributes etc. exists.
|
||||
*
|
||||
* @param {Object} availability exception entity object, which is to be ensured against null values
|
||||
*/
|
||||
export const ensureAvailabilityException = availabilityException => {
|
||||
const empty = { id: null, type: 'availabilityException', attributes: {} };
|
||||
return { ...empty, ...availabilityException };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the display name of the given user. This function handles
|
||||
* missing data (e.g. when the user object is still being downloaded),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,26 @@ import moment from 'moment';
|
|||
export const START_DATE = 'startDate';
|
||||
export const END_DATE = 'endDate';
|
||||
|
||||
/**
|
||||
* Check that the given parameter is a Date object.
|
||||
*
|
||||
* @param {Date} object that should be a Date.
|
||||
*
|
||||
* @returns {boolean} true if given parameter is a Date object.
|
||||
*/
|
||||
export const isDate = d =>
|
||||
d && Object.prototype.toString.call(d) === '[object Date]' && !Number.isNaN(d.getTime());
|
||||
|
||||
/**
|
||||
* Check if the given parameters represent the same Date value (timestamps are compared)
|
||||
*
|
||||
* @param {Date} first param that should be a Date and it should have same timestamp as second param.
|
||||
* @param {Date} second param that should be a Date and it should have same timestamp as second param.
|
||||
*
|
||||
* @returns {boolean} true if given parameters have the same timestamp.
|
||||
*/
|
||||
export const isSameDate = (a, b) => a && isDate(a) && b && isDate(b) && a.getTime() === b.getTime();
|
||||
|
||||
/**
|
||||
* Convert date given by API to something meaningful noon on browser's timezone
|
||||
* So, what happens is that date given by client
|
||||
|
|
@ -94,6 +114,15 @@ export const daysBetween = (startDate, endDate) => {
|
|||
return days;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format the given date
|
||||
*
|
||||
* @param {Date} date to be formatted
|
||||
*
|
||||
* @returns {String} formatted month string
|
||||
*/
|
||||
export const monthIdString = date => moment(date).format('YYYY-MM');
|
||||
|
||||
/**
|
||||
* Format the given date
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,7 +1,33 @@
|
|||
import { fakeIntl } from './test-data';
|
||||
import { nightsBetween, daysBetween, formatDate } from './dates';
|
||||
import { isDate, isSameDate, nightsBetween, daysBetween, formatDate } from './dates';
|
||||
|
||||
describe('date utils', () => {
|
||||
describe('isDate()', () => {
|
||||
it('should return false if parameters is string', () => {
|
||||
expect(isDate('Monday')).toBeFalsy();
|
||||
});
|
||||
it('should return false if parameters is number', () => {
|
||||
expect(isDate('1546293600000')).toBeFalsy();
|
||||
});
|
||||
it('should return false if parameters is incorrect Date', () => {
|
||||
expect(isDate(new Date('random string'))).toBeFalsy();
|
||||
});
|
||||
it('should return true if parameters is Date', () => {
|
||||
expect(isDate(new Date(1546293600000))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameDate()', () => {
|
||||
it('should return falsy if parameters do not match', () => {
|
||||
const a = new Date(1546293600000);
|
||||
const b = new Date(1546293600001);
|
||||
expect(isSameDate(a, b)).toBeFalsy();
|
||||
});
|
||||
it('should be truthy if parameters match', () => {
|
||||
expect(isSameDate(new Date(2019, 0, 1), new Date(2019, 0, 1))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nightsBetween()', () => {
|
||||
it('should fail if end date is before start date', () => {
|
||||
const start = new Date(2017, 0, 2);
|
||||
|
|
|
|||
|
|
@ -108,6 +108,18 @@ export const createOwnListing = (id, attributes = {}, includes = {}) => ({
|
|||
deleted: false,
|
||||
state: LISTING_STATE_PUBLISHED,
|
||||
price: new Money(5500, 'USD'),
|
||||
availabilityPlan: {
|
||||
type: 'availability-plan/day',
|
||||
entries: [
|
||||
{ dayOfWeek: 'mon', seats: 1 },
|
||||
{ dayOfWeek: 'tue', seats: 1 },
|
||||
{ dayOfWeek: 'wed', seats: 1 },
|
||||
{ dayOfWeek: 'thu', seats: 1 },
|
||||
{ dayOfWeek: 'fri', seats: 1 },
|
||||
{ dayOfWeek: 'sat', seats: 1 },
|
||||
{ dayOfWeek: 'sun', seats: 1 },
|
||||
],
|
||||
},
|
||||
publicData: {},
|
||||
...attributes,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -146,6 +146,23 @@ const listingAttributes = shape({
|
|||
publicData: object.isRequired,
|
||||
});
|
||||
|
||||
const AVAILABILITY_PLAN_DAY = 'availability-plan/day';
|
||||
const AVAILABILITY_PLAN_TIME = 'availability-plan/time';
|
||||
export const DAYS_OF_WEEK = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
|
||||
const availabilityPlan = shape({
|
||||
type: oneOf([AVAILABILITY_PLAN_DAY, AVAILABILITY_PLAN_TIME]).isRequired,
|
||||
timezone: string,
|
||||
entries: arrayOf(
|
||||
shape({
|
||||
dayOfWeek: oneOf(DAYS_OF_WEEK).isRequired,
|
||||
seats: number.isRequired,
|
||||
start: string,
|
||||
end: string,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const ownListingAttributes = shape({
|
||||
title: string.isRequired,
|
||||
description: string,
|
||||
|
|
@ -153,6 +170,7 @@ const ownListingAttributes = shape({
|
|||
deleted: propTypes.value(false).isRequired,
|
||||
state: oneOf(LISTING_STATES).isRequired,
|
||||
price: propTypes.money,
|
||||
availabilityPlan: availabilityPlan,
|
||||
publicData: object.isRequired,
|
||||
});
|
||||
|
||||
|
|
@ -202,6 +220,17 @@ propTypes.timeSlot = shape({
|
|||
}),
|
||||
});
|
||||
|
||||
// Denormalised availability exception object
|
||||
propTypes.availabilityException = shape({
|
||||
id: propTypes.uuid.isRequired,
|
||||
type: propTypes.value('availabilityException').isRequired,
|
||||
attributes: shape({
|
||||
end: instanceOf(Date).isRequired,
|
||||
seats: number.isRequired,
|
||||
start: instanceOf(Date).isRequired,
|
||||
}),
|
||||
});
|
||||
|
||||
// When a customer makes a booking to a listing, a transaction is
|
||||
// created with the initial request transition.
|
||||
export const TRANSITION_REQUEST = 'transition/request';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue