diff --git a/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.css b/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.css new file mode 100644 index 0000000000..580c779a3e --- /dev/null +++ b/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.css @@ -0,0 +1,22 @@ +@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) { + margin-bottom: 44px; + } +} diff --git a/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js b/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js new file mode 100644 index 0000000000..70efdef0e9 --- /dev/null +++ b/src/components/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js @@ -0,0 +1,104 @@ +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 = defaultAvailabilityPlan; //currentListing.attributes.availabilityPlan || defaultAvailabilityPlan; + + const panelTitle = isPublished ? ( + }} + /> + ) : ( + + ); + + return ( +
+

{panelTitle}

+ { + // We save default availability plan + // I.e. every night this listing is available + // (exceptions handled with live edit through calendar) + 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.js b/src/components/EditListingWizard/EditListingWizard.js index 74f103970e..262b3680c6 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 a73fe7d22f..622c88bb19 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 556bd74c71..732762b778 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/index.js b/src/components/index.js index 3a3e195ff2..74a334b4af 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 0e1f304b24..6fbc183eb6 100644 --- a/src/containers/EditListingPage/EditListingPage.duck.js +++ b/src/containers/EditListingPage/EditListingPage.duck.js @@ -1,11 +1,68 @@ import omit from 'lodash/omit'; import { types as sdkTypes } from '../../util/sdkLoader'; +import { denormalisedResponseEntities, ensureAvailabilityException } from '../../util/data'; +import { monthIdString, localDateToUTCStartOfDay } from '../../util/dates'; import { storableError } from '../../util/errors'; import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck'; import * as log from '../../util/log'; const { UUID } = sdkTypes; +const isDate = d => d && typeof d.getMonth === 'function'; +const isSameDate = (a, b) => a && isDate(a) && b && isDate(b) && a.getTime() === b.getTime(); + +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 aException = ensureAvailabilityException(e.availabilityException); + const exceptionStart = aException.attributes.start; + const exceptionEnd = aException.attributes.end; + + return !(isSameDate(exceptionStart, start) && isSameDate(exceptionEnd, end)); + }); + + return { + ...calendar, + [monthId]: { ...monthData, exceptions }, + }; +}; + +const addException = (exception, calendar) => { + const { start } = ensureAvailabilityException(exception.availabilityException).attributes; + const monthId = monthIdString(start); + const cleanCalendar = removeException(exception, calendar); + const monthData = cleanCalendar[monthId] || { exceptions: [] }; + + return { + ...cleanCalendar, + [monthId]: { ...monthData, exceptions: [...monthData.exceptions, exception] }, + }; +}; + +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 }, + }; +}; + const requestAction = actionType => params => ({ type: actionType, payload: { params } }); const successAction = actionType => result => ({ type: actionType, payload: result.data }); @@ -33,6 +90,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 +127,18 @@ const initialState = { createListingDraftInProgress: false, submittedListingId: null, redirectToListing: false, + availabilityCalendar: { + // '2018-12': { + // bookings: [], + // exceptions: [], + // fetchError: null, + // fetchInProgress: false, + // fetchBookingsError: null, + // fetchBookingsInProgress: false, + // }, + }, + availabilityCalendarErrors: [], // REMOVE + images: {}, imageOrder: [], removedImageIds: [], @@ -127,12 +212,139 @@ 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 { + ...state, + availabilityCalendar: { + ...state.availabilityCalendar, + [payload.params.monthId]: { + ...state.availabilityCalendar[payload.params.monthId], + fetchBookingsError: null, + fetchBookingsInProgress: true, + }, + }, + }; + case FETCH_BOOKINGS_SUCCESS: + return { + ...state, + availabilityCalendar: { + ...state.availabilityCalendar, + [payload.monthId]: { + ...state.availabilityCalendar[payload.monthId], + bookings: payload.bookings, + fetchBookingsInProgress: false, + }, + }, + }; + case FETCH_BOOKINGS_ERROR: + return { + ...state, + availabilityCalendar: { + ...state.availabilityCalendar, + [payload.monthId]: { + ...state.availabilityCalendar[payload.monthId], + fetchBookingsError: payload.error, + fetchBookingsInProgress: false, + }, + }, + }; + + case FETCH_EXCEPTIONS_REQUEST: + return { + ...state, + availabilityCalendar: { + ...state.availabilityCalendar, + [payload.params.monthId]: { + ...state.availabilityCalendar[payload.params.monthId], + fetchError: null, + fetchInProgress: true, + }, + }, + }; + case FETCH_EXCEPTIONS_SUCCESS: + return { + ...state, + availabilityCalendar: { + ...state.availabilityCalendar, + [payload.monthId]: { + ...state.availabilityCalendar[payload.monthId], + exceptions: payload.exceptions, + fetchInProgress: false, + }, + }, + }; + case FETCH_EXCEPTIONS_ERROR: + return { + ...state, + availabilityCalendar: { + ...state.availabilityCalendar, + [payload.monthId]: { + ...state.availabilityCalendar[payload.monthId], + fetchError: payload.error, + fetchInProgress: 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 cleanCalendar = removeException(exception, state.availabilityCalendar); + + return { + ...state, + availabilityCalendar: addException(exception, cleanCalendar), + }; + } + + case CREATE_EXCEPTION_SUCCESS: + return { + ...state, + availabilityCalendar: updateException(payload.exception, state.availabilityCalendar), //{ ...state.availabilityCalendar, [monthId]: monthDataNew }, + }; + + case CREATE_EXCEPTION_ERROR: { + const { availabilityException, error } = payload; + const failedException = { availabilityException, error }; + return { + ...state, + availabilityCalendar: updateException(failedException, state.availabilityCalendar), // { ...state.availabilityCalendar, [monthId]: monthDataNew }, + }; + } + + case DELETE_EXCEPTION_REQUEST: { + const { id, currentException } = payload.params; + const exception = { ...omit(currentException, ['error']), id, inProgress: true }; + + return { + ...state, + availabilityCalendar: updateException(exception, state.availabilityCalendar), + }; + } + + case DELETE_EXCEPTION_SUCCESS: + return { + ...state, + availabilityCalendar: removeException(payload.exception, state.availabilityCalendar), + }; + + case DELETE_EXCEPTION_ERROR: { + const { availabilityException, error } = payload; + const failedException = { availabilityException, error }; + return { + ...state, + availabilityCalendar: updateException(failedException, state.availabilityCalendar), + }; + } + case UPLOAD_IMAGE_REQUEST: { // payload.params: { id: 'tempId', file } const images = { @@ -237,6 +449,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 +544,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, ...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 0013202381..d3890c3c06 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/examples.js b/src/examples.js index ea21892ffa..aa1798837b 100644 --- a/src/examples.js +++ b/src/examples.js @@ -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, diff --git a/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.css b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.css new file mode 100644 index 0000000000..6c8b629aa2 --- /dev/null +++ b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.css @@ -0,0 +1,65 @@ +@import '../../marketplace.css'; + +:root { + /* + These variables are available in global scope through ":root" + element ( tag). Variables with the same names are going to + overwrite each other if CSS Properties' (PostCSS plugin) + configuration "preserve: true" is used - meaning that variables + are left to CSS bundle. We are planning to enable it in the future + since browsers support CSS Properties already. + */ + + --EditListingPoliciesForm_formMargins: { + margin-bottom: 24px; + + @media (--viewportMedium) { + margin-bottom: 32px; + } + } +} + +.root { + /* Dimensions */ + width: 100%; + height: auto; + + /* Layout */ + display: flex; + flex-grow: 1; + flex-direction: column; + + padding-top: 1px; + + @media (--viewportMedium) { + padding-top: 2px; + } +} + +.title { + @apply --EditListingPoliciesForm_formMargins; +} + +.error { + color: var(--failColor); +} + +.calendarWrapper { + flex-grow: 1; + position: relative; + width: 100%; + height: 100%; + margin-bottom: 24px; +} + +.submitButton { + margin-top: auto; + margin-bottom: 24px; + flex-shrink: 0; + + @media (--viewportLarge) { + display: inline-block; + width: 241px; + margin-top: 100px; + } +} diff --git a/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.example.js b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.example.js new file mode 100644 index 0000000000..a4bfad4b90 --- /dev/null +++ b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.example.js @@ -0,0 +1,15 @@ +/* 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, +// }, +// group: 'forms', +// }; diff --git a/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.js b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.js new file mode 100644 index 0000000000..312f2c8651 --- /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 ( +
+ {errorMessage} +
+ +
+ + +
+ ); + }} + /> + ); + } +} + +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 0000000000..4cd3bb9690 --- /dev/null +++ b/src/forms/EditListingAvailabilityForm/EditListingAvailabilityForm.test.js @@ -0,0 +1,23 @@ +// 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} + /> + ); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.css b/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.css new file mode 100644 index 0000000000..d410697907 --- /dev/null +++ b/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.css @@ -0,0 +1,250 @@ +@import '../../marketplace.css'; + +:root { + --ManageAvailabilityCalendar: #d2d2d2; +} + +.root { + @apply --marketplaceH5FontStyles; + margin-top: 0; + margin-bottom: 0; + overflow-x: hidden; + z-index: 0; + + & :global(.CalendarMonth_caption) { + @apply --marketplaceH2FontStyles; + text-align: left; + padding-bottom: 45px; + + margin-left: 103px; + margin-top: 0; + margin-bottom: 0; + + & strong { + font-weight: 500; + letter-spacing: 0.2px; + } + } + + & :global(.DayPicker) { + box-shadow: none; + } + + & :global(.DayPicker__horizontal) { + background-color: transparent; + margin-left: -18px; + } + + & :global(.DayPicker_week-header) { + top: 55px; + + & small { + font-size: 13px; + font-weight: 500; + } + } + + & :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: 39px; + 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), + & :global(.DayPickerNavigation_button__horizontal) { + width: 40px; + height: 40px; + position: absolute; + top: 19px; + left: 0; + + padding: 7px 15px; + border: solid 1px #e6e6e6; + background-color: var(--matterColorBright); + + &:hover { + & svg { + fill: #000; + } + } + &:focus { + outline-offset: -4px; + } + } + + & :global(.CalendarMonthGrid) { + background-color: transparent; + } + + & :global(.CalendarMonth) { + background-color: transparent; + } + + & :global(.CalendarMonth_table) { + border: 1px solid var(--ManageAvailabilityCalendar); + } + + & :global(.CalendarDay__default) { + border: 0; + background-color: var(--ManageAvailabilityCalendar); + + &:hover { + border: 0; + background-color: var(--ManageAvailabilityCalendar); + } + } + + & :global(.CalendarDay__selected) { + color: var(--matterColor); + } + + & :global(.DayPickerKeyboardShortcuts_show__bottomRight) { + right: -20px; + } +} + +@media (--viewportMedium) { + .root { + margin-top: 0; + margin-bottom: 0; + + & :global(.DayPickerNavigation_leftButton__horizontal), + & :global(.DayPickerNavigation_rightButton__horizontal) { + background-color: var(--matterColorLight); + } + } +} + +@media (--viewportLarge) { + .root { + & :global(.DayPickerNavigation_leftButton__horizontal), + & :global(.DayPickerNavigation_rightButton__horizontal) { + background-color: var(--matterColorLight); + } + } +} + +.dayWrapper { + position: relative; + width: 100%; + height: 100%; +} + +.day { + display: flex; + width: 100%; + height: 100%; + justify-content: flex-end; + align-items: flex-end; + padding: 8px; +} + +.dayNumber { + font-size: 14px; + line-height: 14px; +} + +.default { + composes: day; + background-color: white; +} + +.outsideRange { + composes: day; + background-color: #e6e6e6; + + & .dayNumber { + text-decoration: line-through; + color: lightgrey; + } +} + +.today { + composes: day; + background-color: white; + + & .dayNumber { + text-decoration: underline; + } +} + +.blocked { + composes: day; + background-color: #e6e6e6; +} + +.reserved { + composes: day; + background-color: #e0f8e7; +} + +.inProgress { + position: absolute; + top: 50%; + left: 50%; + margin-top: -14px; + margin-left: -14px; + + width: 28px; + height: 28px; + stroke: var(--marketplaceColor); + stroke-width: 3px; +} + +.monthInProgress { + width: 16px; + height: 16px; + margin-left: 8px; + margin-top: 9px; + + stroke: var(--marketplaceColor); + stroke-width: 4px; +} + +.exceptionError { + opacity: 1; + + /* Animation */ + animation-name: errored; + animation-duration: 800ms; + animation-iteration-count: 1; + animation-timing-function: ease; +} + +@keyframes errored { + 0% { + } + + 30%, + 70% { + background-color: #ffcccc; + /* opacity: 0.2;*/ + } + + 100% { + } +} + +.rootNextMonthIcon, +.rootPreviousMonthIcon { + stroke: var(--matterColor); + fill: var(--matterColor); +} diff --git a/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.js b/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.js new file mode 100644 index 0000000000..ac8d766ade --- /dev/null +++ b/src/forms/EditListingAvailabilityForm/ManageAvailabilityCalendar.js @@ -0,0 +1,406 @@ +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, dateFromLocalToAPI } from '../../util/dates'; +import { IconArrowHead, IconSpinner } from '../../components'; + +import css from './ManageAvailabilityCalendar.css'; + +export 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'); + +const prevMonth = currentMoment => + currentMoment + .clone() + .subtract(1, 'months') + .startOf('month'); +const nextMonth = currentMoment => + currentMoment + .clone() + .add(1, 'months') + .startOf('month'); + +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 }; +}; + +// outside range -><- today ... today+MAX_AVAILABILITY_EXCEPTIONS_RANGE -1 -><- outside range +const isDateOutsideRange = date => { + const endOfRange = MAX_AVAILABILITY_EXCEPTIONS_RANGE - 1; + 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 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 dateFlags = (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, + } = dateFlags(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 ( +
+ + {date.format('DD')} + + {isInProgress ? : null} +
+ ); +}; + +const draftException = (exceptions, start, end, seats) => { + const draft = ensureAvailabilityException({ attributes: { start, end, seats } }); + return { availabilityException: draft }; +}; + +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() { + this.fetchMonthData(this.state.currentMonth); + // Fetch next month too. + this.fetchMonthData(nextMonth(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 start = isPast(monthMoment) + ? TODAY_MOMENT.toDate() + : monthMoment.toDate(); + + // endOfRangeMoment, if the first day of given month is too far in the future + const nextMonthMoment = nextMonth(monthMoment); + const end = isAfterEndOfRange(nextMonthMoment) + ? END_OF_RANGE_MOMENT.toDate() + : nextMonthMoment.toDate(); + + availability.onFetchAvailabilityExceptions({ listingId, start, end }); + + 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 draft = draftException(exceptions, start, end, seatsFromPlan); + const exception = currentException || draft; + const hasAvailabilityException = currentException && currentException.availabilityException.id; + + if (hasAvailabilityException) { + const id = currentException.availabilityException.id; + const isResetToPlanSeats = seatsFromPlan === seats; + + if (isResetToPlanSeats) { + this.props.availability.onDeleteAvailabilityException({ id, currentException: exception }); + } else { + this.props.availability + .onDeleteAvailabilityException({ id, currentException: exception }) + .then(r => { + const params = { listingId, start, end, seats, currentException: exception }; + this.props.availability.onCreateAvailabilityException(params); + }); + } + } else { + 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, + } = dateFlags(availabilityPlan, exceptions, bookings, date) + + + if (isBooked || isPast || isInProgress) { + // Cannot allow or block a reserved or a past date or inProgress + return; + } else if (isBlocked) { + this.onDayAvailabilityChange(date, 1, exceptions); + } else { + this.onDayAvailabilityChange(date, 0, exceptions); + } + } + + onFocusChange() { + // Force the focused states 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 { fetchError, fetchBookingsError } = currentMonthData || {}; + if (currentMonthData && (fetchError || fetchBookingsError)) { + this.fetchMonthData(this.state.currentMonth); + } + + if (onMonthChanged) { + onMonthChanged(monthIdString(this.state.currentMonth)); + } + } + ); + } + + render() { + const { + className, + rootClassName, + listingId, + availability, + availabilityPlan, + onMonthChanged, + monthFormat, + ...rest + } = this.props; + const { focused, date } = this.state; + const { clientWidth: width } = this.dayPickerWrapper || { clientWidth: 0 }; + + const daySize = width > 744 ? 100 : width > 344 ? Math.floor((width - 44) / 7) : 42; + + const calendar = availability.calendar; + const currentMonthData = calendar[monthIdString(this.state.currentMonth)]; + const { fetchInProgress, fetchBookingsInProgress, fetchError, fetchBookingsError } = currentMonthData || {}; + const isMonthDataAvailable = currentMonthData && !fetchInProgress && !fetchBookingsInProgress; + + const classes = classNames(rootClassName || css.root, className); + + return ( +
{ + this.dayPickerWrapper = c; + }} + > + {width > 0 ? ( + { + this.dayPicker = c; + }} + numberOfMonths={1} + navPrev={} + navNext={} + daySize={daySize} + renderDayContents={renderDayContents(calendar, availabilityPlan)} + focused={focused} + date={date} + onDateChange={this.onDateChange} + onFocusChange={this.onFocusChange} + onPrevMonthClick={() => this.onMonthClick(prevMonth)} + onNextMonthClick={() => this.onMonthClick(nextMonth)} + hideKeyboardShortcutsPanel={width < 400} + horizontalMonthPadding={9} + renderMonthElement={({ month }) => ( +
+ {month.format(monthFormat)} + {!isMonthDataAvailable ? : null} +
+ )} + /> + ) : null} + {fetchError && 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 0000000000..116894bfa8 --- /dev/null +++ b/src/forms/EditListingAvailabilityForm/__snapshots__/EditListingAvailabilityForm.test.js.snap @@ -0,0 +1,25 @@ +// 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 00f25fd2e2..399596fa1f 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 a18827bad5..13a97d274a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -128,6 +128,9 @@ "DateInput.closeDatePicker": "Close", "DateInput.defaultPlaceholder": "Date input", "DateInput.screenReaderInputMessage": "Date input", + "EditListingAvailabilityForm.updateFailed": "Failed to update listing. Please try again.", + "EditListingAvailabilityPanel.title": "Manage 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 +199,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/util/contextHelpers.js b/src/util/contextHelpers.js index 97c745f6e4..a61262bd39 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 8d53d015b0..91186ae3da 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 95b1e0c3ec..4a6147ba94 100644 --- a/src/util/dates.js +++ b/src/util/dates.js @@ -57,6 +57,20 @@ export const dateFromLocalToAPI = date => { return momentInLocalTimezone.toDate(); }; +// export const localDateToUTCStartOfDay = date => { +// return moment(date) +// .utc() +// .startOf('day') +// .toDate(); +// }; + +// export const localDateToUTCStartOfDay = date => { +// return moment(date) +// .utc() +// .startOf('day') +// .toDate(); +// }; + /** * Calculate the number of nights between the given dates * @@ -94,6 +108,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/types.js b/src/util/types.js index 0d1f4d6583..e0b771c649 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.isRequired, 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';