import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; import { EventType, PeriodType } from "@prisma/client"; import dayjs, { Dayjs } from "dayjs"; import dayjsBusinessTime from "dayjs-business-time"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { memoize } from "lodash"; import { useEffect, useMemo, useRef, useState } from "react"; import classNames from "@lib/classNames"; import { timeZone } from "@lib/clock"; import { weekdayNames } from "@lib/core/i18n/weekday"; import { doWorkAsync } from "@lib/doWorkAsync"; import { useLocale } from "@lib/hooks/useLocale"; import getSlots from "@lib/slots"; import { WorkingHours } from "@lib/types/schedule"; import Loader from "@components/Loader"; dayjs.extend(dayjsBusinessTime); dayjs.extend(utc); dayjs.extend(timezone); type DatePickerProps = { weekStart: string; onDatePicked: (pickedDate: Dayjs) => void; workingHours: WorkingHours[]; eventLength: number; date: Dayjs | null; periodType: PeriodType; periodStartDate: Date | null; periodEndDate: Date | null; periodDays: number | null; periodCountCalendarDays: boolean | null; minimumBookingNotice: number; }; function isOutOfBounds( time: dayjs.ConfigType, { periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, }: Pick< EventType, "periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate" > ) { const date = dayjs(time); switch (periodType) { case PeriodType.ROLLING: { const periodRollingEndDay = periodCountCalendarDays ? dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day") : dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day"); return date.endOf("day").isAfter(periodRollingEndDay); } case PeriodType.RANGE: { const periodRangeStartDay = dayjs(periodStartDate).utcOffset(date.utcOffset()).endOf("day"); const periodRangeEndDay = dayjs(periodEndDate).utcOffset(date.utcOffset()).endOf("day"); return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay); } case PeriodType.UNLIMITED: default: return false; } } function DatePicker({ weekStart, onDatePicked, workingHours, eventLength, date, periodType = PeriodType.UNLIMITED, periodStartDate, periodEndDate, periodDays, periodCountCalendarDays, minimumBookingNotice, }: DatePickerProps): JSX.Element { const { i18n } = useLocale(); const [browsingDate, setBrowsingDate] = useState(date); const [month, setMonth] = useState(""); const [year, setYear] = useState(""); const [isFirstMonth, setIsFirstMonth] = useState(false); const [daysFromState, setDays] = useState< | { disabled: Boolean; date: number; }[] | null >(null); useEffect(() => { if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) { setBrowsingDate(date || dayjs().tz(timeZone())); } }, [date, browsingDate]); useEffect(() => { if (browsingDate) { setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" })); setYear(browsingDate.format("YYYY")); setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs())); setDays(null); } }, [browsingDate, i18n.language]); const isDisabled = ( day: number, { browsingDate, periodType, periodStartDate, periodEndDate, periodCountCalendarDays, periodDays, eventLength, minimumBookingNotice, workingHours, } ) => { const date = browsingDate.startOf("day").date(day); return ( isOutOfBounds(date, { periodType, periodStartDate, periodEndDate, periodCountCalendarDays, periodDays, }) || !getSlots({ inviteeDate: date, frequency: eventLength, minimumBookingNotice, workingHours, }).length ); }; const isDisabledRef = useRef( memoize(isDisabled, (day, { browsingDate }) => { // Make a composite cache key return day + "_" + browsingDate.toString(); }) ); const days = (() => { if (!browsingDate) { return []; } if (daysFromState) { return daysFromState; } // Create placeholder elements for empty days in first week let weekdayOfFirst = browsingDate.date(1).day(); if (weekStart === "Monday") { weekdayOfFirst -= 1; if (weekdayOfFirst < 0) weekdayOfFirst = 6; } const days = Array(weekdayOfFirst).fill(null); const isDisabledMemoized = isDisabledRef.current; const daysInMonth = browsingDate.daysInMonth(); const daysInitialOffset = days.length; // Build UI with All dates disabled for (let i = 1; i <= daysInMonth; i++) { days.push({ disabled: true, date: i, }); } // Update dates with their availability doWorkAsync({ batch: 1, name: "DatePicker", length: daysInMonth, callback: (i: number, isLast) => { let day = i + 1; days[daysInitialOffset + i] = { disabled: isDisabledMemoized(day, { browsingDate, periodType, periodStartDate, periodEndDate, periodCountCalendarDays, periodDays, eventLength, minimumBookingNotice, workingHours, }), date: day, }; }, batchDone: () => { setDays([...days]); }, }); return days; // eslint-disable-next-line react-hooks/exhaustive-deps })(); if (!browsingDate) { return ; } // Handle month changes const incrementMonth = () => { setBrowsingDate(browsingDate?.add(1, "month")); }; const decrementMonth = () => { setBrowsingDate(browsingDate?.subtract(1, "month")); }; return (
{month}{" "} {year}
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
{weekDay}
))}
{days.map((day, idx) => (
{day === null ? (
) : ( )}
))}
); } export default DatePicker;