diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa0d486a..606e4e4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2024-XX-XX +- [add] Add support for using multiple seats on default-booking process. + [#502](https://github.com/sharetribe/web-template/pull/502) - [fix] ListingPageVariant: the #author anchor was not pointing to ListingPageVariant. [#515](https://github.com/sharetribe/web-template/pull/515) - [fix] target element didn't seem to work well with scroll-margin. diff --git a/server/api-util/lineItems.js b/server/api-util/lineItems.js index c7b33ac03..ac2581837 100644 --- a/server/api-util/lineItems.js +++ b/server/api-util/lineItems.js @@ -72,6 +72,14 @@ const getHourQuantityAndLineItems = orderData => { return { quantity, extraLineItems: [] }; }; +const getHoursWithSeatsAndLineItems = orderData => { + const { bookingStart, bookingEnd, seats } = orderData || {}; + const units = + bookingStart && bookingEnd ? calculateQuantityFromHours(bookingStart, bookingEnd) : null; + + return { units, seats, extraLineItems: [] }; +}; + /** * Calculate quantity based on days or nights between given bookingDates. * @@ -87,6 +95,13 @@ const getDateRangeQuantityAndLineItems = (orderData, code) => { return { quantity, extraLineItems: [] }; }; +const getDateRangeWithSeatsAndLineItems = (orderData, code) => { + const { bookingStart, bookingEnd, seats } = orderData; + const units = + bookingStart && bookingEnd ? calculateQuantityFromDates(bookingStart, bookingEnd, code) : null; + return { units, seats, extraLineItems: [] }; +}; + /** * Returns collection of lineItems (max 50) * @@ -140,18 +155,30 @@ exports.transactionLineItems = (listing, orderData, providerCommission, customer const quantityAndExtraLineItems = unitType === 'item' ? getItemQuantityAndLineItems(orderData, publicData, currency) + : unitType === 'hour' && orderData.seats + ? getHoursWithSeatsAndLineItems(orderData) : unitType === 'hour' ? getHourQuantityAndLineItems(orderData) + : ['day', 'night'].includes(unitType) && orderData.seats + ? getDateRangeWithSeatsAndLineItems(orderData, code) : ['day', 'night'].includes(unitType) ? getDateRangeQuantityAndLineItems(orderData, code) : {}; - const { quantity, extraLineItems } = quantityAndExtraLineItems; + const { quantity, units, seats, extraLineItems } = quantityAndExtraLineItems; // Throw error if there is no quantity information given - if (!quantity) { - const message = `Error: transition should contain quantity information: - stockReservationQuantity, quantity, or bookingStart & bookingEnd (if "line-item/day" or "line-item/night" is used)`; + if (!quantity && !(units && seats)) { + const missingFields = []; + + if (!quantity) missingFields.push('quantity'); + if (!units) missingFields.push('units'); + if (!seats) missingFields.push('seats'); + + const message = `Error: orderData is missing the following information: ${missingFields.join( + ', ' + )}. Quantity or either units & seats is required.`; + const error = new Error(message); error.status = 400; error.statusText = message; @@ -169,10 +196,11 @@ exports.transactionLineItems = (listing, orderData, providerCommission, customer * * By default OrderBreakdown prints line items inside LineItemUnknownItemsMaybe if the lineItem code is not recognized. */ + const quantityOrSeats = !!units && !!seats ? { units, seats } : { quantity }; const order = { code, unitPrice, - quantity, + ...quantityOrSeats, includeFor: ['customer', 'provider'], }; diff --git a/src/components/CustomExtendedDataField/CustomExtendedDataField.js b/src/components/CustomExtendedDataField/CustomExtendedDataField.js index 3acd1ec2a..e0f12da4d 100644 --- a/src/components/CustomExtendedDataField/CustomExtendedDataField.js +++ b/src/components/CustomExtendedDataField/CustomExtendedDataField.js @@ -144,6 +144,18 @@ const CustomFieldLong = props => { label={label} placeholder={placeholder} validate={value => validate(value, minimum, maximum)} + onWheel={e => { + // fix: number input should not change value on scroll + if (e.target === document.activeElement) { + // Prevent the input value change, because we prefer page scrolling + e.target.blur(); + + // Refocus immediately, on the next tick (after the current function is done) + setTimeout(() => { + e.target.focus(); + }, 0); + } + }} /> ); }; diff --git a/src/components/FieldNumber/FieldNumber.example.js b/src/components/FieldNumber/FieldNumber.example.js new file mode 100644 index 000000000..ce4c28d86 --- /dev/null +++ b/src/components/FieldNumber/FieldNumber.example.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { Form as FinalForm, FormSpy } from 'react-final-form'; +import { Button } from '../../components'; +import FieldNumber from './FieldNumber'; + +const formName = 'Styleguide.FieldNumber.Form'; + +const FormComponent = props => ( + { + const { form, handleSubmit, onChange, invalid, pristine, submitting } = fieldRenderProps; + + const submitDisabled = invalid || pristine || submitting; + + return ( +
{ + e.preventDefault(); + handleSubmit(e); + }} + > + + + + + + ); + }} + /> +); + +export const Number = { + component: FormComponent, + props: { + onChange: formState => { + if (Object.keys(formState.values).length > 0) { + console.log('form values changed to:', formState.values); + } + }, + onSubmit: values => { + console.log('Submit values of FieldNumber: ', values); + }, + }, + group: 'inputs', +}; diff --git a/src/components/FieldNumber/FieldNumber.js b/src/components/FieldNumber/FieldNumber.js new file mode 100644 index 000000000..68b4639a9 --- /dev/null +++ b/src/components/FieldNumber/FieldNumber.js @@ -0,0 +1,156 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Field } from 'react-final-form'; +import { injectIntl } from '../../util/reactIntl'; + +import css from './FieldNumber.module.css'; + +const decrement = 'decrement'; +const increment = 'increment'; + +const getIconClasses = props => { + const { className, disabled } = props; + const classes = classNames(className, css.iconContainer, { [css.disabled]: disabled }); + const iconClassName = classNames(css.icon, { [css.disabled]: disabled }); + + return { + classes, + iconClassName, + }; +}; + +const IconMinus = props => { + const { classes, iconClassName } = getIconClasses(props); + + return ( + + + + + ); +}; + +const IconPlus = props => { + const { classes, iconClassName } = getIconClasses(props); + + return ( + + + + + ); +}; + +const NumberInputComponent = props => { + const { value: rawValue, onChange } = props.input; + const { initialValue, minValue = 0, maxValue, svgClassName, intl } = props; + const value = + rawValue && rawValue >= minValue + ? Number.parseInt(rawValue) + : initialValue && initialValue >= minValue + ? Number.parseInt(initialValue) + : minValue; + + const handleValueChange = event => { + const { name } = event.target; + if (name === increment) { + onChange(value + 1); + } else if (name === decrement) { + onChange(value - 1); + } + }; + + return ( + + + {value} + + + ); +}; + +const NumberInput = injectIntl(NumberInputComponent); + +/** + * Renders a numeric selector with - and + icons + * @component + * @param {Object} props + * @param {string} props.id + * @param {string} props.name + * @param {string?} props.className + * @param {string?} props.rootClassName + * @param {string?} props.svgClassName + * @param {string?} props.textClassName + * @param {string?} props.label + * @param {number?} props.maxValue + * @param {number?} props.minValue + * @returns {JSX.Element} containing a numeric selector field that can be used in a form + */ +const FieldNumberComponent = props => { + const { rootClassName, className, textClassName, id, name, label } = props; + + const classes = classNames(rootClassName || css.root, className); + + return ( + + {label ? ( + + ) : null} + + {fieldRenderProps => { + return ( +
+ +
+ ); + }} +
+
+ ); +}; + +export default FieldNumberComponent; diff --git a/src/components/FieldNumber/FieldNumber.module.css b/src/components/FieldNumber/FieldNumber.module.css new file mode 100644 index 000000000..ab18b4d2d --- /dev/null +++ b/src/components/FieldNumber/FieldNumber.module.css @@ -0,0 +1,91 @@ +@import '../../styles/customMediaQueries.css'; + +.root { + position: relative; +} + +.numberInputWrapper { + align-self: baseline; + height: 40px; + + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + min-width: 104px; +} + +.numberButton { + stroke-width: 1px; + border-radius: 10%; + fill: var(--colorSecondaryButton); + height: 40px; + width: 40px; + cursor: pointer; + display: flex; + padding: 0px; + align-items: center; + justify-content: center; + border: none; + + svg { + pointer-events: none; + } +} + +.numberButton:hover { + .iconContainer { + stroke: #b8bfd1; + } +} + +.numberButton:focus { + outline: none; + + .iconContainer { + stroke: #b8bfd1; + } +} +.numberButton:active { + .iconContainer { + fill: var(--colorGrey50); + stroke: #b8bfd1; + } +} +.numberButton:disabled { + cursor: not-allowed; + + .disabled.iconContainer { + fill: var(--colorSecondaryButton); + stroke: #d8dce6; + } +} + +.numberInput { + width: 60px; + text-align: center; + padding: 0px 10px; + cursor: default; + font-size: 16px; + font-weight: var(--fontWeightMedium); +} + +.iconContainer { + fill: var(--colorWhite); + /* stroke color set to match the color of the input + borders defined in marketplaceDefaults.css */ + stroke: #d8dce6; + stroke-width: 1px; +} + +.icon { + stroke: var(--colorGrey700); + fill: var(--colorGrey700); +} + +.disabled { + .icon { + stroke: var(--colorGrey200); + fill: var(--colorGrey200); + } +} diff --git a/src/components/OrderBreakdown/LineItemBasePriceMaybe.js b/src/components/OrderBreakdown/LineItemBasePriceMaybe.js index 6a48989bc..1aba7a161 100644 --- a/src/components/OrderBreakdown/LineItemBasePriceMaybe.js +++ b/src/components/OrderBreakdown/LineItemBasePriceMaybe.js @@ -23,15 +23,26 @@ const LineItemBasePriceMaybe = props => { // These are defined in '../../util/types'; const unitPurchase = lineItems.find(item => item.code === code && !item.reversal); - const quantity = unitPurchase ? unitPurchase.quantity.toString() : null; + const quantity = unitPurchase?.units + ? unitPurchase.units.toString() + : unitPurchase?.quantity + ? unitPurchase.quantity.toString() + : null; const unitPrice = unitPurchase ? formatMoney(intl, unitPurchase.unitPrice) : null; const total = unitPurchase ? formatMoney(intl, unitPurchase.lineTotal) : null; + const message = unitPurchase?.seats ? ( + + ) : ( + + ); + return quantity && total ? (
- - - + {message} {total}
) : null; diff --git a/src/components/OrderPanel/BookingDatesForm/BookingDatesForm.js b/src/components/OrderPanel/BookingDatesForm/BookingDatesForm.js index 0f8c5492b..9ce1b1eab 100644 --- a/src/components/OrderPanel/BookingDatesForm/BookingDatesForm.js +++ b/src/components/OrderPanel/BookingDatesForm/BookingDatesForm.js @@ -23,7 +23,7 @@ import { LINE_ITEM_DAY, propTypes } from '../../../util/types'; import { timeSlotsPerDate } from '../../../util/generators'; import { BOOKING_PROCESS_NAME } from '../../../transactions/transaction'; -import { Form, PrimaryButton, FieldDateRangePicker, H6 } from '../../../components'; +import { Form, PrimaryButton, FieldDateRangePicker, FieldSelect, H6 } from '../../../components'; import EstimatedCustomerBreakdownMaybe from '../EstimatedCustomerBreakdownMaybe'; @@ -337,21 +337,27 @@ const handleMonthClick = ( // lineItems from this Template's backend for the EstimatedTransactionMaybe // In case you add more fields to the form, make sure you add // the values here to the orderData object. -const handleFormSpyChange = ( + +const calculateLineItems = ( listingId, isOwnListing, fetchLineItemsInProgress, - onFetchTransactionLineItems + onFetchTransactionLineItems, + seatsEnabled ) => formValues => { - const { startDate, endDate } = - formValues.values && formValues.values.bookingDates ? formValues.values.bookingDates : {}; + const { startDate, endDate, seats } = formValues?.values || {}; + + const seatCount = seats ? parseInt(seats, 10) : 1; + + const orderData = { + bookingStart: startDate, + bookingEnd: endDate, + ...(seatsEnabled && { seats: seatCount }), + }; if (startDate && endDate && !fetchLineItemsInProgress) { onFetchTransactionLineItems({ - orderData: { - bookingStart: startDate, - bookingEnd: endDate, - }, + orderData, listingId, isOwnListing, }); @@ -373,6 +379,103 @@ const showPreviousMonthStepper = (currentMonth, timeZone) => { return isDateSameOrAfter(prevMonthDate, currentMonthDate); }; +const getStartAndEndOnTimeZone = (startDate, endDate, isDaily, timeZone) => { + // Parse the startDate and endDate into the target time zone + const parsedStart = startDate + ? getStartOf(timeOfDayFromLocalToTimeZone(startDate, timeZone), 'day', timeZone) + : startDate; + + const parsedEnd = endDate + ? getStartOf(timeOfDayFromLocalToTimeZone(endDate, timeZone), 'day', timeZone) + : endDate; + + // Adjust endDate for API if isDaily is true + const endDateForAPI = parsedEnd && isDaily ? getExclusiveEndDate(parsedEnd, timeZone) : parsedEnd; + + // Return the processed dates + return { startDate: parsedStart, endDate: endDateForAPI }; +}; + +// return a list of timeslots that exist between startDate and endDate +const filterTimeSlotsByDate = (allTimeSlots, startDate, endDate) => { + return Object.values(allTimeSlots).filter( + ({ attributes: { start, end } }) => + // Check if the timeslot is within or overlaps with the selected dates + (start < startDate && end > startDate) || + (start >= startDate && end <= endDate) || + (start < endDate && end > endDate) + ); +}; + +// Finds the timeslot with the smallest number of seats +const findMinSeatsTimeSlot = timeSlots => { + return timeSlots.reduce((minSeatsSlot, timeSlot) => { + const { seats } = timeSlot.attributes; + return !minSeatsSlot || seats < minSeatsSlot.seats ? timeSlot.attributes : minSeatsSlot; + }, null); +}; + +// Main function to get the seat options based on the minimum seats available in the date range +const getMinSeatsOptions = (allTimeSlots, startDate, endDate) => { + if (!startDate || !endDate) { + return []; + } + const filteredTimeSlots = filterTimeSlotsByDate(allTimeSlots, startDate, endDate); + const minSeatsSlot = findMinSeatsTimeSlot(filteredTimeSlots); + + // Return the array of seat options from 1 to the minimum seats available, capped at 100 + const maxOptions = 100; + return minSeatsSlot + ? Array.from({ length: Math.min(minSeatsSlot.seats, maxOptions) }, (_, i) => i + 1) + : []; +}; + +// Checks if two timeslots are consequtive +const areConsecutiveTimeSlots = (timeSlotA, timeSlotB) => + new Date(timeSlotA.attributes.end).getTime() === new Date(timeSlotB.attributes.start).getTime(); + +// Find the index of a the first consecutive timeslot in a list of timeslots +const findIndexOfFirstConsecutiveTimeSlot = (timeSlots, index) => + index > 0 && areConsecutiveTimeSlots(timeSlots[index - 1], timeSlots[index]) + ? findIndexOfFirstConsecutiveTimeSlot(timeSlots, index - 1) + : index; + +// find the index of the last consecutive timeslot in a list of timeslots +const findIndexOfLastConsecutiveTimeSlot = (timeSlots, index) => + index < timeSlots.length - 1 && areConsecutiveTimeSlots(timeSlots[index], timeSlots[index + 1]) + ? findIndexOfLastConsecutiveTimeSlot(timeSlots, index + 1) + : index; + +// Find and combine adjacent/consecutive timeslots into one timeslot +const combineConsecutiveTimeSlots = (slots, startDate) => { + // Locate the index of the timeslot containing startDate + const startIndex = slots.findIndex(({ attributes }) => { + const { start, end } = attributes; + const startTime = new Date(start).getTime(); + const endTime = new Date(end).getTime(); + return startDate.getTime() >= startTime && startDate.getTime() < endTime; + }); + + // Return empty array if no timeslot matches startDate + if (startIndex === -1) return []; + + // Determine the full range of consecutive timeslots + const indexOfFirstTimeSlot = findIndexOfFirstConsecutiveTimeSlot(slots, startIndex); + const indexOfLastTimeSlot = findIndexOfLastConsecutiveTimeSlot(slots, startIndex); + + // Combine the consecutive timeslots into a single slot + const combinedSlot = { + ...slots[indexOfFirstTimeSlot], + attributes: { + ...slots[indexOfFirstTimeSlot].attributes, + start: slots[indexOfFirstTimeSlot].attributes.start, + end: slots[indexOfLastTimeSlot].attributes.end, + }, + }; + + return [combinedSlot]; +}; + export const BookingDatesFormComponent = props => { const { rootClassName, @@ -388,6 +491,7 @@ export const BookingDatesFormComponent = props => { payoutDetailsWarning, monthlyTimeSlots, onMonthChanged, + seatsEnabled, ...rest } = props; @@ -438,12 +542,14 @@ export const BookingDatesFormComponent = props => { const classes = classNames(rootClassName || css.root, className); - const onFormSpyChange = handleFormSpyChange( + const onHandleFetchLineItems = calculateLineItems( listingId, isOwnListing, fetchLineItemsInProgress, - onFetchTransactionLineItems + onFetchTransactionLineItems, + seatsEnabled ); + return ( { lineItems, fetchLineItemsError, onFetchTimeSlots, + form: formApi, } = formRenderProps; const { startDate, endDate } = values && values.bookingDates ? values.bookingDates : {}; @@ -482,7 +589,6 @@ export const BookingDatesFormComponent = props => { endDate, } : null; - const showEstimatedBreakdown = breakdownData && lineItems && !fetchLineItemsInProgress && !fetchLineItemsError; @@ -499,6 +605,11 @@ export const BookingDatesFormComponent = props => { const endDatePlaceholderText = endDatePlaceholder || intl.formatDate(tomorrow, dateFormatOptions); + const relevantTimeSlots = + seatsEnabled && startDate && !endDate + ? combineConsecutiveTimeSlots(allTimeSlots, startDate) + : allTimeSlots; + const onMonthClick = handleMonthClick( currentMonth, monthlyTimeSlots, @@ -508,7 +619,7 @@ export const BookingDatesFormComponent = props => { onFetchTimeSlots ); const isDayBlocked = isDayBlockedFn({ - allTimeSlots, + allTimeSlots: relevantTimeSlots, monthlyTimeSlots, isDaily: lineItemUnitType === LINE_ITEM_DAY, startDate, @@ -516,19 +627,26 @@ export const BookingDatesFormComponent = props => { timeZone, }); const isOutsideRange = isOutsideRangeFn( - allTimeSlots, + relevantTimeSlots, monthlyTimeSlots, startDate, endDate, lineItemUnitType, dayCountAvailableForBooking, - timeZone + timeZone, + seatsEnabled + ); + + const seatsOptions = getMinSeatsOptions( + relevantTimeSlots, + values?.bookingDates?.startDate, + values?.bookingDates?.endDate ); const isDaily = lineItemUnitType === LINE_ITEM_DAY; + return (
- { }} parse={v => { const { startDate, endDate } = v || {}; - // Parse the DateRangePicker's value (local 00:00) for the Final Form - // The form expects listing's time zone and start of day aka 00:00 - const parsedStart = startDate - ? getStartOf(timeOfDayFromLocalToTimeZone(startDate, timeZone), 'day', timeZone) - : startDate; - const parsedEnd = endDate - ? getStartOf(timeOfDayFromLocalToTimeZone(endDate, timeZone), 'day', timeZone) - : endDate; - const endDateForAPI = - parsedEnd && isDaily ? getExclusiveEndDate(parsedEnd, timeZone) : parsedEnd; - return v ? { startDate: parsedStart, endDate: endDateForAPI } : v; + return v ? getStartAndEndOnTimeZone(startDate, endDate, isDaily, timeZone) : v; }} useMobileMargins validate={composeValidators( @@ -582,7 +690,7 @@ export const BookingDatesFormComponent = props => { )} isDayBlocked={isDayBlocked} isOutsideRange={isOutsideRange} - isBlockedBetween={isBlockedBetween(allTimeSlots, timeZone)} + isBlockedBetween={isBlockedBetween(relevantTimeSlots, timeZone)} disabled={fetchLineItemsInProgress} showPreviousMonthStepper={showPreviousMonthStepper(currentMonth, timeZone)} showNextMonthStepper={showNextMonthStepper( @@ -598,8 +706,57 @@ export const BookingDatesFormComponent = props => { onClose={() => { setCurrentMonth(startDate || endDate || startOfToday); }} + onChange={values => { + const { startDate: startDateFromValues, endDate: endDateFromValues } = values || {}; + const { startDate, endDate } = values + ? getStartAndEndOnTimeZone( + startDateFromValues, + endDateFromValues, + isDaily, + timeZone + ) + : {}; + if (seatsEnabled) { + formApi.change('seats', 1); + } + onHandleFetchLineItems({ + values: { + startDate, + endDate, + seats: seatsEnabled ? 1 : undefined, + }, + }); + }} /> + {seatsEnabled ? ( + { + onHandleFetchLineItems({ + values: { + startDate: startDate, + endDate: endDate, + seats: values, + }, + }); + }} + > + + {seatsOptions.map(s => ( + + ))} + + ) : null} + {showEstimatedBreakdown ? (
diff --git a/src/components/OrderPanel/BookingDatesForm/BookingDatesForm.module.css b/src/components/OrderPanel/BookingDatesForm/BookingDatesForm.module.css index 25a030c7a..9dd1e693e 100644 --- a/src/components/OrderPanel/BookingDatesForm/BookingDatesForm.module.css +++ b/src/components/OrderPanel/BookingDatesForm/BookingDatesForm.module.css @@ -76,3 +76,13 @@ stroke: var(--colorWhite); fill: var(--colorWhite); } + +.fieldSeats { + width: calc(100% - 48px); + margin: 30px 24px 6px 24px; + + @media (--viewportMedium) { + width: 100%; + margin: 24px 0; + } +} diff --git a/src/components/OrderPanel/BookingTimeForm/BookingTimeForm.js b/src/components/OrderPanel/BookingTimeForm/BookingTimeForm.js index 5d91bf1a6..97928056f 100644 --- a/src/components/OrderPanel/BookingTimeForm/BookingTimeForm.js +++ b/src/components/OrderPanel/BookingTimeForm/BookingTimeForm.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { array, bool, func, number, object, string } from 'prop-types'; import { compose } from 'redux'; import { Form as FinalForm } from 'react-final-form'; @@ -9,7 +9,7 @@ import { timestampToDate } from '../../../util/dates'; import { propTypes } from '../../../util/types'; import { BOOKING_PROCESS_NAME } from '../../../transactions/transaction'; -import { Form, H6, PrimaryButton } from '../../../components'; +import { Form, H6, PrimaryButton, FieldSelect } from '../../../components'; import EstimatedCustomerBreakdownMaybe from '../EstimatedCustomerBreakdownMaybe'; import FieldDateAndTimeInput from './FieldDateAndTimeInput'; @@ -21,18 +21,30 @@ import css from './BookingTimeForm.module.css'; // In case you add more fields to the form, make sure you add // the values here to the orderData object. const handleFetchLineItems = props => formValues => { - const { listingId, isOwnListing, fetchLineItemsInProgress, onFetchTransactionLineItems } = props; - const { bookingStartTime, bookingEndTime } = formValues.values; + const { + listingId, + isOwnListing, + fetchLineItemsInProgress, + onFetchTransactionLineItems, + seatsEnabled, + } = props; + const { bookingStartTime, bookingEndTime, seats } = formValues.values; const startDate = bookingStartTime ? timestampToDate(bookingStartTime) : null; const endDate = bookingEndTime ? timestampToDate(bookingEndTime) : null; // Note: we expect values bookingStartTime and bookingEndTime to be strings // which is the default case when the value has been selected through the form const isStartBeforeEnd = bookingStartTime < bookingEndTime; + const seatsMaybe = seatsEnabled && seats > 0 ? { seats: parseInt(seats, 10) } : {}; if (bookingStartTime && bookingEndTime && isStartBeforeEnd && !fetchLineItemsInProgress) { + const orderData = { + bookingStart: startDate, + bookingEnd: endDate, + ...seatsMaybe, + }; onFetchTransactionLineItems({ - orderData: { bookingStart: startDate, bookingEnd: endDate }, + orderData, listingId, isOwnListing, }); @@ -46,9 +58,12 @@ export const BookingTimeFormComponent = props => { price: unitPrice, dayCountAvailableForBooking, marketplaceName, + seatsEnabled, ...rest } = props; + const [seatsOptions, setSeatsOptions] = useState([1]); + const classes = classNames(rootClassName || css.root, className); return ( @@ -94,10 +109,14 @@ export const BookingTimeFormComponent = props => { const showEstimatedBreakdown = breakdownData && lineItems && !fetchLineItemsInProgress && !fetchLineItemsError; + const onHandleFetchLineItems = handleFetchLineItems(props); + return ( {monthlyTimeSlots && timeZone ? ( { pristine={pristine} timeZone={timeZone} dayCountAvailableForBooking={dayCountAvailableForBooking} - handleFetchLineItems={handleFetchLineItems(props)} + handleFetchLineItems={onHandleFetchLineItems} /> ) : null} + {seatsEnabled ? ( + { + onHandleFetchLineItems({ + values: { + bookingStartDate: startDate, + bookingStartTime: startTime, + bookingEndDate: endDate, + bookingEndTime: endTime, + seats: values, + }, + }); + }} + > + + {seatsOptions.map(s => ( + + ))} + + ) : null} {showEstimatedBreakdown ? (
diff --git a/src/components/OrderPanel/BookingTimeForm/BookingTimeForm.module.css b/src/components/OrderPanel/BookingTimeForm/BookingTimeForm.module.css index 779fea1b3..76a7405d6 100644 --- a/src/components/OrderPanel/BookingTimeForm/BookingTimeForm.module.css +++ b/src/components/OrderPanel/BookingTimeForm/BookingTimeForm.module.css @@ -77,3 +77,13 @@ margin-top: 72px; } } + +.fieldSeats { + width: calc(100% - 48px); + margin: 30px 24px 6px 24px; + + @media (--viewportMedium) { + width: 100%; + margin: 24px 0; + } +} diff --git a/src/components/OrderPanel/BookingTimeForm/FieldDateAndTimeInput.example.js b/src/components/OrderPanel/BookingTimeForm/FieldDateAndTimeInput.example.js index 89f367435..81b592f5b 100644 --- a/src/components/OrderPanel/BookingTimeForm/FieldDateAndTimeInput.example.js +++ b/src/components/OrderPanel/BookingTimeForm/FieldDateAndTimeInput.example.js @@ -11,6 +11,7 @@ import FieldDateAndTimeInput from './FieldDateAndTimeInput'; const { UUID } = sdkTypes; const identity = v => v; +const noop = () => {}; const options = { weekday: 'short', month: 'long', day: 'numeric' }; const placeholderText = new Intl.DateTimeFormat('en-US', options).format(new Date()); @@ -153,6 +154,7 @@ const FormComponent = props => ( form={form} pristine={pristine} timeZone={timeZone} + setSeatsOptions={noop} dayCountAvailableForBooking={dayCountAvailableForBooking} />
-
- -
+ {!hasMultipleSeatsInUSe ? ( + + ) : null}
{useFullDays ? ( @@ -95,7 +145,6 @@ const EditListingAvailabilityExceptionForm = props => { formId={formId} listingId={listingId} intl={intl} - formApi={formApi} allExceptions={allExceptions} monthlyExceptionQueries={monthlyExceptionQueries} onFetchExceptions={onFetchExceptions} @@ -120,6 +169,17 @@ const EditListingAvailabilityExceptionForm = props => { )}
+ {hasMultipleSeatsInUSe ? ( + + ) : null} +
{updateListingError ? (

@@ -137,31 +197,4 @@ const EditListingAvailabilityExceptionForm = props => { ); }; -EditListingAvailabilityExceptionForm.defaultProps = { - className: null, - rootClassName: null, - fetchErrors: null, - formId: null, - monthlyExceptionQueries: null, - allExceptions: [], -}; - -EditListingAvailabilityExceptionForm.propTypes = { - className: string, - rootClassName: string, - formId: string, - monthlyExceptionQueries: object, - allExceptions: arrayOf(propTypes.availabilityException), - intl: intlShape.isRequired, - onSubmit: func.isRequired, - isDaily: bool.isRequired, - useFullDays: bool.isRequired, - timeZone: string.isRequired, - updateInProgress: bool.isRequired, - fetchErrors: shape({ - updateListingError: propTypes.error, - }), - onFetchExceptions: func.isRequired, -}; - -export default compose(injectIntl)(EditListingAvailabilityExceptionForm); +export default EditListingAvailabilityExceptionForm; diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/EditListingAvailabilityExceptionForm.module.css b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/EditListingAvailabilityExceptionForm.module.css index 89274ae5a..db4241fab 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/EditListingAvailabilityExceptionForm.module.css +++ b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/EditListingAvailabilityExceptionForm.module.css @@ -5,6 +5,7 @@ .heading, .radioButtons, +.seatsInput, .submitButton { padding-left: 24px; padding-right: 24px; @@ -23,10 +24,8 @@ .section { display: flex; flex-direction: column; - margin-bottom: 48px; @media (--viewportMedium) { - margin-bottom: 200px; padding-left: 60px; padding-right: 60px; } @@ -38,13 +37,19 @@ fill: var(--colorWhite); } +.seatsInput { + margin-top: 24px; +} + .submitButton { margin-top: auto; flex-shrink: 0; /* Mobile phones introduce bottom-bar, for which we need to give 96px vertical space */ padding-bottom: 96px; + margin-top: 48px; @media (--viewportMedium) { + margin-top: 200px; padding-bottom: 0; } } diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateRange.js b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateRange.js index eda6891d8..fe1a5a64f 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateRange.js +++ b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateRange.js @@ -141,6 +141,38 @@ const handleFocusedInputChange = setFocusedInput => focusedInput => { ////////////////////////////////////////// // EditListingAvailabilityExceptionForm // ////////////////////////////////////////// +/** + * @typedef {Object} AvailabilityException + * @property {string} id + * @property {'availabilityException'} type 'availabilityException' + * @property {Object} attributes API entity's attributes + * @property {Date} attributes.start The start of availability exception (inclusive) + * @property {Date} attributes.end The end of availability exception (exclusive) + * @property {Number} attributes.seats The number of seats available (0 means 'unavailable') + */ +/** + * @typedef {Object} MonthlyExceptionQueryInfo + * @property {Object?} fetchExceptionsError + * @property {boolean} fetchExceptionsInProgress + */ + +/** + * A DateRange field for the form + * + * @component + * @param {Object} props + * @param {string?} props.formId + * @param {UUID?} props.listingId listing's id + * @param {ReactIntl} props.intl + * @param {Array} props.allExceptions + * @param {Object.?} props.monthlyExceptionQueries E.g. '2022-12': { fetchExceptionsError, fetchExceptionsInProgress } + * @param {Function} props.onFetchExceptions Redux Thunk function to fetch AvailabilityExceptions + * @param {Function} props.onMonthChanged Redux Thunk function to fetch AvailabilityExceptions + * @param {string} props.timeZone IANA time zone key (listing's time zone) + * @param {boolean} props.isDaily + * @param {Object} props.values form's values + * @returns {JSX.Element} containing date range field + */ const ExceptionDateRange = props => { const [focusedInput, setFocusedInput] = useState(null); const [currentMonth, setCurrentMonth] = useState(getStartOf(TODAY, 'month', props.timeZone)); diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateTimeRange.js b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateTimeRange.js index 94236b88d..f041992be 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateTimeRange.js +++ b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateTimeRange.js @@ -286,6 +286,37 @@ const onExceptionEndDateChange = (value, availableSlots, props) => { ////////////////////////////////////////// // EditListingAvailabilityExceptionForm // ////////////////////////////////////////// +/** + * @typedef {Object} AvailabilityException + * @property {string} id + * @property {'availabilityException'} type 'availabilityException' + * @property {Object} attributes API entity's attributes + * @property {Date} attributes.start The start of availability exception (inclusive) + * @property {Date} attributes.end The end of availability exception (exclusive) + * @property {Number} attributes.seats The number of seats available (0 means 'unavailable') + */ +/** + * @typedef {Object} MonthlyExceptionQueryInfo + * @property {Object?} fetchExceptionsError + * @property {boolean} fetchExceptionsInProgress + */ + +/** + * A DateRange field for the form + * + * @component + * @param {Object} props + * @param {string?} props.formId + * @param {UUID?} props.listingId listing's id + * @param {ReactIntl} props.intl + * @param {Array} props.allExceptions + * @param {Object.?} props.monthlyExceptionQueries E.g. '2022-12': { fetchExceptionsError, fetchExceptionsInProgress } + * @param {Function} props.onFetchExceptions Redux Thunk function to fetch AvailabilityExceptions + * @param {Function} props.onMonthChanged Redux Thunk function to fetch AvailabilityExceptions + * @param {string} props.timeZone IANA time zone key (listing's time zone) + * @param {Object} props.values form's values + * @returns {JSX.Element} containing form that allows adding availability exceptions + */ const ExceptionDateTimeRange = props => { const [currentMonth, setCurrentMonth] = useState(getStartOf(TODAY, 'month', props.timeZone)); const { diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateTimeRange.module.css b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateTimeRange.module.css index 19a954b30..9fb051dc4 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateTimeRange.module.css +++ b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityExceptionForm/ExceptionDateTimeRange.module.css @@ -96,7 +96,7 @@ &::after { left: 12px; - bottom: 21px; + bottom: 18px; } } } @@ -110,7 +110,7 @@ position: absolute; bottom: 13px; left: 12px; - background-image: url('data:image/svg+xml;utf8,'); + background-image: url('data:image/svg+xml;utf8,'); background-size: 12px 12px; width: 12px; height: 12px; @@ -118,7 +118,7 @@ @media (--viewportMedium) { &::after { left: 12px; - bottom: 21px; + bottom: 18px; } } } @@ -138,4 +138,9 @@ .selectDisabled { composes: select; background-image: url('data:image/svg+xml;utf8,'); + + &:disabled { + opacity: 1; + color: var(--colorGrey400); + } } diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js index 19c8928e8..096596d1f 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js +++ b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPanel.js @@ -1,11 +1,10 @@ import React, { useState } from 'react'; -import { arrayOf, bool, func, object, string } from 'prop-types'; import classNames from 'classnames'; // Import configs and util modules import { FormattedMessage } from '../../../../util/reactIntl'; import { getDefaultTimeZoneOnBrowser, timestampToDate } from '../../../../util/dates'; -import { LISTING_STATE_DRAFT, propTypes } from '../../../../util/types'; +import { AVAILABILITY_MULTIPLE_SEATS, LISTING_STATE_DRAFT } from '../../../../util/types'; import { DAY, isFullDay } from '../../../../transactions/transaction'; // Import shared components @@ -40,7 +39,7 @@ const createEntryDayGroups = (entries = {}) => { // Collect info about which days are active in the availability plan form: let activePlanDays = []; return entries.reduce((groupedEntries, entry) => { - const { startTime, endTime: endHour, dayOfWeek } = entry; + const { startTime, endTime: endHour, dayOfWeek, seats } = entry; const dayGroup = groupedEntries[dayOfWeek] || []; activePlanDays = activePlanDays.includes(dayOfWeek) ? activePlanDays @@ -52,6 +51,7 @@ const createEntryDayGroups = (entries = {}) => { { startTime, endTime: endHour === '00:00' ? '24:00' : endHour, + seats, }, ], activePlanDays, @@ -59,8 +59,8 @@ const createEntryDayGroups = (entries = {}) => { }, {}); }; -// Create initial values -const createInitialValues = availabilityPlan => { +// Create initial values for the availability plan +const createInitialPlanValues = availabilityPlan => { const { timezone, entries } = availabilityPlan || {}; const tz = timezone || defaultTimeZone(); return { @@ -74,12 +74,12 @@ const createEntriesFromSubmitValues = values => WEEKDAYS.reduce((allEntries, dayOfWeek) => { const dayValues = values[dayOfWeek] || []; const dayEntries = dayValues.map(dayValue => { - const { startTime, endTime } = dayValue; + const { startTime, endTime, seats } = dayValue; // Note: This template doesn't support seats yet. return startTime && endTime ? { dayOfWeek, - seats: 1, + seats: seats ?? 1, startTime, endTime: endTime === '24:00' ? '00:00' : endTime, } @@ -101,6 +101,54 @@ const createAvailabilityPlan = values => ({ ////////////////////////////////// // EditListingAvailabilityPanel // ////////////////////////////////// + +/** + * @typedef {Object} AvailabilityException + * @property {string} id + * @property {'availabilityException'} type 'availabilityException' + * @property {Object} attributes attributes + * @property {Date} attributes.start The start of availability exception (inclusive) + * @property {Date} attributes.end The end of availability exception (exclusive) + * @property {Number} attributes.seats the number of seats available (0 means 'unavailable') + */ +/** + * @typedef {Object} ExceptionQueryInfo + * @property {Object|null} fetchExceptionsError + * @property {boolean} fetchExceptionsInProgress + */ + +/** + * A panel where provider can set availabilityPlan (weekly default schedule) + * and AvailabilityExceptions. + * In addition, it combines the set values of both of those and shows a weekly schedule. + * + * @component + * @param {Object} props + * @param {string?} props.className + * @param {string?} props.rootClassName + * @param {Object} props.params pathparams + * @param {Object?} props.locationSearch parsed search params + * @param {Object?} props.listing listing entity from API (draft/published/etc.) + * @param {Array} props.listingTypes listing type config from asset delivery API + * @param {boolean} props.disabled + * @param {boolean} props.ready + * @param {Object.?} props.monthlyExceptionQueries E.g. '2022-12': { fetchExceptionsError, fetchExceptionsInProgress } + * @param {Object.?} props.weeklyExceptionQueries E.g. '2022-12-14': { fetchExceptionsError, fetchExceptionsInProgress } + * @param {Array} props.allExceptions + * @param {Function} props.onAddAvailabilityException + * @param {Function} props.onDeleteAvailabilityException + * @param {Function} props.onFetchExceptions + * @param {Function} props.onSubmit + * @param {Function} props.onManageDisableScrolling + * @param {Function} props.onNextTab + * @param {string} props.submitButtonText + * @param {boolean} props.updateInProgress + * @param {Object} props.errors + * @param {Object} props.config app config + * @param {Object} props.routeConfiguration + * @param {Object} props.history history from React Router + * @returns {JSX.Element} containing form that allows adding availability exceptions + */ const EditListingAvailabilityPanel = props => { const { className, @@ -108,9 +156,10 @@ const EditListingAvailabilityPanel = props => { params, locationSearch, listing, + listingTypes, monthlyExceptionQueries, weeklyExceptionQueries, - allExceptions, + allExceptions = [], onAddAvailabilityException, onDeleteAvailabilityException, disabled, @@ -134,8 +183,12 @@ const EditListingAvailabilityPanel = props => { const firstDayOfWeek = config.localization.firstDayOfWeek; const classes = classNames(rootClassName || css.root, className); const listingAttributes = listing?.attributes; - const unitType = listingAttributes?.publicData?.unitType; + const { listingType, unitType } = listingAttributes?.publicData || {}; + const listingTypeConfig = listingTypes.find(conf => conf.listingType === listingType); + const useFullDays = isFullDay(unitType); + const useMultipleSeats = listingTypeConfig?.availabilityType === AVAILABILITY_MULTIPLE_SEATS; + const hasAvailabilityPlan = !!listingAttributes?.availabilityPlan; const isPublished = listing?.id && listingAttributes?.state !== LISTING_STATE_DRAFT; const defaultAvailabilityPlan = { @@ -152,11 +205,11 @@ const EditListingAvailabilityPanel = props => { ], }; const availabilityPlan = listingAttributes?.availabilityPlan || defaultAvailabilityPlan; - const initialValues = valuesFromLastSubmit + const initialPlanValues = valuesFromLastSubmit ? valuesFromLastSubmit - : createInitialValues(availabilityPlan); + : createInitialPlanValues(availabilityPlan); - const handleSubmit = values => { + const handlePlanSubmit = values => { setValuesFromLastSubmit(values); // Final Form can wait for Promises to return. @@ -173,10 +226,9 @@ const EditListingAvailabilityPanel = props => { // Save exception click handler const saveException = values => { - const { availability, exceptionStartTime, exceptionEndTime, exceptionRange } = values; + const { availability, exceptionStartTime, exceptionEndTime, exceptionRange, seats } = values; - // TODO: add proper seat handling - const seats = availability === 'available' ? 1 : 0; + const seatCount = seats != null ? seats : availability === 'available' ? 1 : 0; // Exception date/time range is given through FieldDateRangeInput or // separate time fields. @@ -192,7 +244,7 @@ const EditListingAvailabilityPanel = props => { const params = { listingId: listing.id, - seats, + seats: seatCount, ...range, }; @@ -207,7 +259,7 @@ const EditListingAvailabilityPanel = props => { return (
-

+

{isPublished ? ( { weeklyExceptionQueries={weeklyExceptionQueries} isDaily={unitType === DAY} useFullDays={useFullDays} + useMultipleSeats={useMultipleSeats} onDeleteAvailabilityException={onDeleteAvailabilityException} onFetchExceptions={onFetchExceptions} params={params} @@ -303,11 +356,13 @@ const EditListingAvailabilityPanel = props => { listingTitle={listingAttributes?.title} availabilityPlan={availabilityPlan} weekdays={rotateDays(WEEKDAYS, firstDayOfWeek)} - useFullDays={useFullDays} - onSubmit={handleSubmit} - initialValues={initialValues} + onSubmit={handlePlanSubmit} + initialValues={initialPlanValues} inProgress={updateInProgress} fetchErrors={errors} + useFullDays={useFullDays} + useMultipleSeats={useMultipleSeats} + unitType={unitType} /> ) : null} @@ -330,9 +385,10 @@ const EditListingAvailabilityPanel = props => { onFetchExceptions={onFetchExceptions} onSubmit={saveException} timeZone={availabilityPlan.timezone} - isDaily={unitType === DAY} + unitType={unitType} updateInProgress={updateInProgress} useFullDays={useFullDays} + listingTypeConfig={listingTypeConfig} /> ) : null} @@ -340,34 +396,4 @@ const EditListingAvailabilityPanel = props => { ); }; -EditListingAvailabilityPanel.defaultProps = { - className: null, - rootClassName: null, - listing: null, - monthlyExceptionQueries: null, - weeklyExceptionQueries: null, - allExceptions: [], -}; - -EditListingAvailabilityPanel.propTypes = { - className: string, - rootClassName: string, - - // We cannot use propTypes.listing since the listing might be a draft. - listing: object, - disabled: bool.isRequired, - ready: bool.isRequired, - monthlyExceptionQueries: object, - weeklyExceptionQueries: object, - allExceptions: arrayOf(propTypes.availabilityException), - onAddAvailabilityException: func.isRequired, - onDeleteAvailabilityException: func.isRequired, - onSubmit: func.isRequired, - onManageDisableScrolling: func.isRequired, - onNextTab: func.isRequired, - submitButtonText: string.isRequired, - updateInProgress: bool.isRequired, - errors: object.isRequired, -}; - export default EditListingAvailabilityPanel; diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPanel.module.css b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPanel.module.css index 5f11cdda9..3d7bdc7b1 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPanel.module.css +++ b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPanel.module.css @@ -8,6 +8,14 @@ flex-direction: column; } +.heading { + padding: 0; + margin-bottom: 24px; + @media (--viewportMedium) { + margin-bottom: 16px; + } +} + .sectionHeader { display: flex; flex-direction: row; @@ -18,25 +26,34 @@ @media (--viewportMedium) { align-items: baseline; - padding: 5px 0 3px 0; + padding: 3px 0 5px 0; gap: 8px; margin-top: 0; } } .planInfo { + margin-bottom: 24px; + @media (--viewportMedium) { - padding: 1px 0 7px 0; + padding: 6px 0 2px 0; + margin-bottom: 8px; } } .section { - margin: 24px 0 0 0; + margin: 0 0 24px 0; + + @media (--viewportMedium) { + margin: 0 0 32px 0; + } } .editPlanButton { + display: block; + padding: 4px 0 2px 0; margin: 0; - padding: 1px 0 5px 0; + @media (--viewportMedium) { padding: 5px 0 3px 0; } @@ -48,7 +65,8 @@ margin: 0 0 24px 0; @media (--viewportMedium) { - padding: 3px 0 5px 0; + display: block; + padding: 1px 0 7px 0; margin: 0 0 72px 0; } } diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPlanForm/AvailabilityPlanEntries.js b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPlanForm/AvailabilityPlanEntries.js index 77739cda4..3e5398693 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPlanForm/AvailabilityPlanEntries.js +++ b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/EditListingAvailabilityPlanForm/AvailabilityPlanEntries.js @@ -4,7 +4,15 @@ import { FieldArray } from 'react-final-form-arrays'; import classNames from 'classnames'; import { FormattedMessage } from '../../../../../util/reactIntl'; -import { InlineTextButton, IconClose, FieldSelect, FieldCheckbox } from '../../../../../components'; + +import { + InlineTextButton, + FieldSelect, + FieldCheckbox, + IconDelete, +} from '../../../../../components'; + +import FieldSeatsInput from '../FieldSeatsInput/FieldSeatsInput'; import css from './AvailabilityPlanEntries.module.css'; @@ -58,6 +66,16 @@ const sortEntries = (defaultCompareReturn = 0) => (a, b) => { // Curried: find entry by comparing start time and end time const findEntryFn = entry => e => e.startTime === entry.startTime && e.endTime === entry.endTime; +/** + * AvailabilityPlan entry. + * + * @typedef {Object} AvailabilityPlanEntry + * @property {String} dayOfWeek - the day of week shorthand. E.g. 'Mon'. + * @property {String} startTime - start hour. E.g. '09:00'. + * @property {String} endTime - end hour. E.g. '17:00'. + * @property {Number} seats - the number of available seats 0...Number.MAX_SAFE_INTEGER + */ + /** * From all the available start hours, filter only those start hours that can be used * in the current entry creation. @@ -65,8 +83,8 @@ const findEntryFn = entry => e => e.startTime === entry.startTime && e.endTime = * For start hours this mainly means situation where end hours is set first. * * @param {Array} availableStartHours (hours are in format: '13:00') - * @param {*} entries created entries: [{ startTime: '13:00', endTime: '17:00' }] - * @param {*} index index in the Final Form Array: current dayOfWeek + * @param {Array} entries created entries: [{ startTime: '13:00', endTime: '17:00' }] + * @param {Number} index index in the Final Form Array: current dayOfWeek * @returns returns only those start hours that are allowed to be selected. */ const filterStartHours = (availableStartHours, entries, index) => { @@ -103,8 +121,8 @@ const filterStartHours = (availableStartHours, entries, index) => { * For end hour this only means a situation where start hour is set first. * * @param {Array} availableEndHours (hours are in format: '13:00') - * @param {*} entries created entries: [{ startTime: '13:00', endTime: '17:00' }] - * @param {*} index index in the Final Form Array: current dayOfWeek + * @param {Array} entries created entries: [{ startTime: '13:00', endTime: '17:00' }] + * @param {Number} index index in the Final Form Array: current dayOfWeek * @returns returns only those end hours that are allowed to be selected. */ const filterEndHours = (availableEndHours, entries, index) => { @@ -138,12 +156,11 @@ const filterEndHours = (availableEndHours, entries, index) => { /** * Find all the entries that boundaries are already reserved. * - * @param {*} entries look like this [{ startTime: '13:00', endTime: '17:00' }] - * @param {*} intl - * @param {*} findStartHours find start hours (00:00 ... 23:00) or else (01:00 ... 24:00) + * @param {Array} entries look like this [{ startTime: '13:00', endTime: '17:00' }] + * @param {Boolean} findStartHours find start hours (00:00 ... 23:00) or else (01:00 ... 24:00) * @returns array of reserved sharp hours. E.g. ['13:00', '14:00', '15:00', '16:00'] */ -const getEntryBoundaries = (entries, intl, findStartHours) => index => { +const getEntryBoundaries = (entries, findStartHours) => index => { const boundaryDiff = findStartHours ? 0 : 1; return entries.reduce((allHours, entry, i) => { @@ -165,6 +182,21 @@ const getEntryBoundaries = (entries, intl, findStartHours) => index => { /** * Date pickers that create time range inside the day: start hour - end hour + * + * @component + * @param {Object} props - The component props + * @param {string} props.name - the name of the form field/input + * @param {Number} props.index - the index in the Final Form Array for the current dayOfWeek + * @param {Array} props.availableStartHours - array of strings represeting start hours: '00:00', '01:00', etc. + * @param {Array} props.availableEndHours - array of strings represeting end hours: '01:00', '02:00', etc. + * @param {Function} props.isTimeSetFn - Check if 'startTime' or 'endTime' is set for the form + * @param {Boolean} props.isNextDay - flag if the selected 'endTime' is the next day aka (24:00) + * @param {Array} props.entries - AvailabilityPlan entries: [['Mon[0]']: ]] + * @param {Function} props.onRemove - a function to remove plan entry + * @param {String} props.unitType - 'hour', 'day', 'night' + * @param {Boolean} props.useMultipleSeats - true if availabilityType is 'multipleSeats' + * @param {ReactIntl} props.intl - React Intl instance + * @returns {JSX.Element} The component that allows selecting plan entries */ const TimeRangeSelects = props => { const { @@ -176,74 +208,116 @@ const TimeRangeSelects = props => { isNextDay, entries, onRemove, + unitType, + useMultipleSeats, intl, } = props; return ( -
-
- -