import { addDays, isAfter, isBefore, endOfDay, isEqual } from 'date-fns' import { DateRange, Matcher } from 'react-day-picker' import { DisableCalendarDates, RangeContext } from '../CalendarTypes' type Props = { range: DateRange | undefined newDisableCalendarDates?: DisableCalendarDates setCalendarRange: (range: DateRange | undefined) => void setCalendarHasError?: (arg: boolean) => void calendarRange?: DateRange overlappingDate?: DateRange[] calendarHasError?: boolean rangeContext?: RangeContext } export const calendarSelectionRules = ({ range, newDisableCalendarDates, setCalendarRange, calendarRange, overlappingDate, setCalendarHasError, rangeContext, }: Props) => { // Get and parse needed data const rangeFrom = range?.from ? endOfDay(range.from) : null const rangeTo = range?.to ? endOfDay(range.to) : null const calendarRangeFrom = calendarRange?.from ? endOfDay(calendarRange.from) : null const calendarRangeTo = calendarRange?.to ? endOfDay(calendarRange.to) : null const rangeContextFrom = rangeContext?.from ? endOfDay(rangeContext.from) : null const rangeContextTo = rangeContext?.to ? endOfDay(rangeContext.to) : null // When some dates are not available for selection, the earliest date will be available only for "end" // and the latest date will only be available for "start" const rangeFromDate = !range && calendarRangeFrom && calendarRangeTo ? calendarRangeFrom : rangeFrom const overlappingDateFrom = overlappingDate?.length ? overlappingDate.find((date) => !!(date.from && rangeFromDate) ? isEqual(endOfDay(date.from), rangeFromDate) : false ) : null const overlappingDateTo = overlappingDate?.length ? overlappingDate.find((date) => !!(date.from && rangeTo) ? isEqual(endOfDay(date.from), rangeTo) : false ) : null //---------- const checkOutRange = newDisableCalendarDates?.availableDates?.length ? newDisableCalendarDates.availableDates.find((checkInDate) => !!(checkInDate.checkIn && rangeFrom) ? isEqual(endOfDay(checkInDate.checkIn), rangeFrom) : false ) : null // Calendar selection rules: The cases are handled sequentially, starting from simple selections to more complex contextual selections. // The rules are processed in a specific order, so one case is handled before another. // Changing the order will cause the rules to break or not work properly. if ( (calendarRangeFrom && overlappingDateFrom && rangeFrom && !isEqual(rangeFrom, calendarRangeFrom)) || !range || (calendarRangeFrom && calendarRangeTo && overlappingDateTo && rangeTo && !isEqual(rangeTo, calendarRangeTo)) ) { // 1. If dates overlap, clear the selection. return setCalendarRange(undefined) } if ( // 2. If selected "start" is after "range context end", clear selection and display error. // Also, if there are unavailable dates between the rangeContext "start" and "end", do not allow a fully overlapping selection. (rangeFrom && rangeContextTo && isAfter(rangeFrom, rangeContextTo)) || // In case of current rangeTo is after contextRange and user clicks the new check-in (calendarRangeFrom && calendarRangeTo && rangeContextTo && rangeTo && // If rangeTo has been adjusted, the current click was at the end // In this case we can verify if the new click (check-in-to-be) goes over the context range end !isEqual(rangeTo, calendarRangeTo) && isAfter(rangeTo, rangeContextTo)) ) { setCalendarHasError && setCalendarHasError(true) return setCalendarRange(undefined) } if (rangeFrom && rangeTo && isEqual(rangeFrom, rangeTo)) { // 3. If "start" is selected and the same date is clicked again, keep the current "start" selection. return setCalendarRange({ from: rangeFrom, to: undefined }) } if (calendarRangeFrom && calendarRangeTo && !range) { // 4. If "calendarRange" has dates selected and the same "start" date is clicked, the "range" will become undefined. // Set the current selection (calendarRange) to the initial "start" of calendarRange. return setCalendarRange({ from: calendarRangeFrom, to: undefined }) } // 5. Handle gap selection backwards and forwards if ( // In the range context case, every date selection that is after selected "start" last checkout, // clear selection and prompt error rangeContext && rangeTo && checkOutRange?.lastCheckOut && isAfter(rangeTo, endOfDay(checkOutRange.lastCheckOut)) && // Handle case of existing range that span over the end of the context range ((calendarRangeTo && !isEqual(rangeTo, calendarRangeTo)) || !calendarRangeTo) && isAfter(rangeTo, endOfDay(rangeContext.to)) ) { setCalendarHasError && setCalendarHasError(true) return setCalendarRange(undefined) } if ( // Enforce on every date selection before current "start", mark as "start" selection rangeFrom && calendarRangeFrom && isBefore(rangeFrom, calendarRangeFrom) ) { return setCalendarRange({ from: rangeFrom, to: undefined }) } if ( // Every date selection that is after selected "start" last checkout, mark as "start" rangeTo && checkOutRange?.lastCheckOut && isAfter(rangeTo, endOfDay(checkOutRange.lastCheckOut)) ) { if (overlappingDateTo) { return setCalendarRange(undefined) } return setCalendarRange({ from: rangeTo, to: undefined }) } if ( calendarRangeFrom && rangeFrom && !isEqual(calendarRangeFrom, rangeFrom) ) { // 6. If "calendarRange" has selected dates and the new selection "start" differs from the current "calendarRange start", reset and make a new "start" selection. return setCalendarRange({ from: rangeFrom, to: undefined }) } else if (calendarRangeTo && rangeTo && !isEqual(calendarRangeTo, rangeTo)) { // 7. If "calendarRange" has selected dates and the new selection "end" differs from the current "calendarRange end", reset and make a new "start" selection. return setCalendarRange({ from: rangeTo, to: undefined }) } if ( // 8. If selected "end" is before "context start", mark as "start" and display error rangeFrom && rangeTo && rangeContextFrom && isBefore(rangeTo, rangeContextFrom) ) { setCalendarHasError && setCalendarHasError(true) return setCalendarRange({ from: rangeTo, to: undefined }) } setCalendarRange(range) }