diff --git a/CHANGELOG.md b/CHANGELOG.md
index a79f0486..e9545d30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.css b/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.css
new file mode 100644
index 00000000..7f1247d1
--- /dev/null
+++ b/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.css
@@ -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;
+ }
+}
diff --git a/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js b/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js
new file mode 100644
index 00000000..1ffef499
--- /dev/null
+++ b/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js
@@ -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 (
+
+
+ {isPublished ? (
+ }}
+ />
+ ) : (
+
+ )}
+
+ {
+ // 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}
+ />
+
+ );
+};
+
+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;
diff --git a/src/components/EditListingWizard/EditListingWizard.css b/src/components/EditListingWizard/EditListingWizard.css
index 95609596..4bdde170 100644
--- a/src/components/EditListingWizard/EditListingWizard.css
+++ b/src/components/EditListingWizard/EditListingWizard.css
@@ -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);
}
diff --git a/src/components/EditListingWizard/EditListingWizard.js b/src/components/EditListingWizard/EditListingWizard.js
index 74f10397..262b3680 100644
--- a/src/components/EditListingWizard/EditListingWizard.js
+++ b/src/components/EditListingWizard/EditListingWizard.js
@@ -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:
diff --git a/src/components/EditListingWizard/EditListingWizardTab.js b/src/components/EditListingWizard/EditListingWizardTab.js
index a73fe7d2..622c88bb 100644
--- a/src/components/EditListingWizard/EditListingWizardTab.js
+++ b/src/components/EditListingWizard/EditListingWizardTab.js
@@ -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 (
+ {
+ 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({
diff --git a/src/components/ManageListingCard/ManageListingCard.js b/src/components/ManageListingCard/ManageListingCard.js
index 556bd74c..732762b7 100644
--- a/src/components/ManageListingCard/ManageListingCard.js
+++ b/src/components/ManageListingCard/ManageListingCard.js
@@ -334,6 +334,16 @@ export const ManageListingCardComponent = props => {
>
+
+ {' • '}
+
+
+
+
diff --git a/src/components/ManageListingCard/__snapshots__/ManageListingCard.test.js.snap b/src/components/ManageListingCard/__snapshots__/ManageListingCard.test.js.snap
index 5b0eee88..4105f5d5 100644
--- a/src/components/ManageListingCard/__snapshots__/ManageListingCard.test.js.snap
+++ b/src/components/ManageListingCard/__snapshots__/ManageListingCard.test.js.snap
@@ -144,6 +144,25 @@ exports[`ManageListingCard matches snapshot 1`] = `
values={Object {}}
/>
+
+ •
+
+
+
+
diff --git a/src/components/index.js b/src/components/index.js
index 3a3e195f..74a334b4 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -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';
diff --git a/src/containers/EditListingPage/EditListingPage.duck.js b/src/containers/EditListingPage/EditListingPage.duck.js
index 0e1f304b..096a8a39 100644
--- a/src/containers/EditListingPage/EditListingPage.duck.js
+++ b/src/containers/EditListingPage/EditListingPage.duck.js
@@ -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.
diff --git a/src/containers/EditListingPage/EditListingPage.js b/src/containers/EditListingPage/EditListingPage.js
index 00132023..d3890c3c 100644
--- a/src/containers/EditListingPage/EditListingPage.js
+++ b/src/containers/EditListingPage/EditListingPage.js
@@ -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)),
diff --git a/src/containers/EditListingPage/EditListingPage.test.js b/src/containers/EditListingPage/EditListingPage.test.js
index 5a9637fc..b85c65b2 100644
--- a/src/containers/EditListingPage/EditListingPage.test.js
+++ b/src/containers/EditListingPage/EditListingPage.test.js
@@ -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}
diff --git a/src/containers/EditListingPage/__snapshots__/EditListingPage.test.js.snap b/src/containers/EditListingPage/__snapshots__/EditListingPage.test.js.snap
index 9a7f6d6b..dc1bbae2 100644
--- a/src/containers/EditListingPage/__snapshots__/EditListingPage.test.js.snap
+++ b/src/containers/EditListingPage/__snapshots__/EditListingPage.test.js.snap
@@ -7,6 +7,15 @@ exports[`EditListingPageComponent matches snapshot 1`] = `
>
{
+ 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',
+};
diff --git a/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.js b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.js
new file mode 100644
index 00000000..312f2c86
--- /dev/null
+++ b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.js
@@ -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 (
+ {
+ const {
+ className,
+ rootClassName,
+ disabled,
+ handleSubmit,
+ //intl,
+ invalid,
+ pristine,
+ saveActionMsg,
+ updated,
+ updateError,
+ updateInProgress,
+ availability,
+ availabilityPlan,
+ listingId,
+ } = fieldRenderProps;
+
+ const errorMessage = updateError ? (
+
+
+
+ ) : null;
+
+ const classes = classNames(rootClassName || css.root, className);
+ const submitReady = updated && pristine;
+ const submitInProgress = updateInProgress;
+ const submitDisabled = invalid || disabled || submitInProgress;
+
+ return (
+
+ );
+ }}
+ />
+ );
+ }
+}
+
+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);
diff --git a/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.test.js b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.test.js
new file mode 100644
index 00000000..4d27dab5
--- /dev/null
+++ b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.test.js
@@ -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(
+ 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();
+ });
+});
diff --git a/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.css b/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.css
new file mode 100644
index 00000000..949084ed
--- /dev/null
+++ b/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.css
@@ -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;
+ }
+ }
+}
diff --git a/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.js b/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.js
new file mode 100644
index 00000000..f07ac165
--- /dev/null
+++ b/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.js
@@ -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 (
+
+
+ {isInProgress ? (
+
+ ) : (
+ {date.format('D')}
+ )}
+
+
+ );
+};
+
+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 (
+ {
+ this.dayPickerWrapper = c;
+ }}
+ >
+ {width > 0 ? (
+
+
{
+ this.dayPicker = c;
+ }}
+ numberOfMonths={1}
+ navPrev={}
+ navNext={}
+ 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 }) => (
+
+ {month.format(monthFormat)}
+ {!isMonthDataFetched ? : null}
+
+ )}
+ />
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {fetchExceptionsError && fetchBookingsError ? (
+
+
+
+ ) : null}
+
+ );
+ }
+}
+
+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;
diff --git a/src/forms/EditListingAvailabilityForm/__snapshots__/EditListingAvailabilityForm.test.js.snap b/src/forms/EditListingAvailabilityForm/__snapshots__/EditListingAvailabilityForm.test.js.snap
new file mode 100644
index 00000000..6c40e151
--- /dev/null
+++ b/src/forms/EditListingAvailabilityForm/__snapshots__/EditListingAvailabilityForm.test.js.snap
@@ -0,0 +1,34 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EditListingAvailabilityForm matches snapshot 1`] = `
+
+`;
diff --git a/src/forms/index.js b/src/forms/index.js
index 00f25fd2..399596fa 100644
--- a/src/forms/index.js
+++ b/src/forms/index.js
@@ -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';
diff --git a/src/translations/en.json b/src/translations/en.json
index a18827ba..4900d5ff 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -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",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index edd59d15..6d2c2089 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -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"
-}
+}
\ No newline at end of file
diff --git a/src/util/contextHelpers.js b/src/util/contextHelpers.js
index 97c745f6..a61262bd 100644
--- a/src/util/contextHelpers.js
+++ b/src/util/contextHelpers.js
@@ -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();
diff --git a/src/util/data.js b/src/util/data.js
index 8d53d015..91186ae3 100644
--- a/src/util/data.js
+++ b/src/util/data.js
@@ -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),
diff --git a/src/util/dates.js b/src/util/dates.js
index 95b1e0c3..a5cbdbf1 100644
--- a/src/util/dates.js
+++ b/src/util/dates.js
@@ -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
*
diff --git a/src/util/dates.test.js b/src/util/dates.test.js
index b3876cd8..5cd3bacf 100644
--- a/src/util/dates.test.js
+++ b/src/util/dates.test.js
@@ -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);
diff --git a/src/util/test-data.js b/src/util/test-data.js
index a910f074..b47347ee 100644
--- a/src/util/test-data.js
+++ b/src/util/test-data.js
@@ -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,
},
diff --git a/src/util/types.js b/src/util/types.js
index 0d1f4d65..d3a981f6 100644
--- a/src/util/types.js
+++ b/src/util/types.js
@@ -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';