import { useEffect } from "react"; import { shallow } from "zustand/shallow"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { useEmbedStyles } from "@calcom/embed-core/embed-iframe"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth"; import type { Slots } from "@calcom/features/calendars/lib/types"; import { daysInMonth, yyyymmdd } from "@calcom/lib/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { weekdayNames } from "@calcom/lib/weekday"; import type { PeriodData } from "@calcom/types/Event"; import classNames from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; import { SkeletonText } from "@calcom/ui/components/skeleton"; import { Tooltip } from "@calcom/ui/components/tooltip"; import NoAvailabilityDialog from "./NoAvailabilityDialog"; import { useSlotsViewOnSmallScreen } from "@calcom/embed-core/embed-iframe"; export type DatePickerProps = { /** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */ weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6; /** Fires whenever a selected date is changed. */ onChange: (date: Dayjs | null, omitUpdatingParams?: boolean) => void; /** Fires when the month is changed. */ onMonthChange?: (date: Dayjs) => void; /** which date or dates are currently selected (not tracked from here) */ selected?: Dayjs | Dayjs[] | null; /** defaults to current date. */ minDate?: Date; /** Furthest date selectable in the future, default = UNLIMITED */ maxDate?: Date; /** locale, any IETF language tag, e.g. "hu-HU" - defaults to Browser settings */ locale: string; /** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */ excludedDates?: string[]; /** defaults to all, which dates are bookable (inverse of excludedDates) */ includedDates?: string[]; /** allows adding classes to the container */ className?: string; /** Shows a small loading spinner next to the month name */ isLoading?: boolean; /** used to query the multiple selected dates */ eventSlug?: string; /** To identify days that are not available and should display OOO and redirect if toUser exists */ slots?: Slots; periodData?: PeriodData; // Whether this is a compact sidebar view or main monthly view isCompact?: boolean; // Whether to show the no availability dialog showNoAvailabilityDialog?: boolean; }; const Day = ({ date, active, disabled, away, emoji, customClassName, showMonthTooltip, isFirstDayOfNextMonth, ...props }: JSX.IntrinsicElements["button"] & { active: boolean; date: Dayjs; away?: boolean; emoji?: string | null; customClassName?: { dayContainer?: string; dayActive?: string; }; showMonthTooltip?: boolean; isFirstDayOfNextMonth?: boolean; }) => { const { t } = useLocale(); const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton"); const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton"); const buttonContent = ( ); const content = showMonthTooltip ? ( {buttonContent} ) : ( buttonContent ); return ( <> {isFirstDayOfNextMonth && (
{date.format("MMM")}
)} {content} ); }; const Days = ({ minDate, excludedDates = [], browsingDate, weekStart, DayComponent = Day, selected, month, nextMonthButton, eventSlug, slots, customClassName, isBookingInPast, periodData, isCompact, showNoAvailabilityDialog = true, ...props }: Omit & { DayComponent?: React.FC>; browsingDate: Dayjs; weekStart: number; month: string | null; nextMonthButton: () => void; customClassName?: { datePickerDate?: string; datePickerDateActive?: string; }; scrollToTimeSlots?: () => void; isBookingInPast: boolean; periodData: PeriodData; isCompact?: boolean; }) => { const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen(); const layout = useBookerStoreContext((state) => state.layout); const isMobile = layout === "mobile"; const includedDates = getAvailableDatesInMonth({ browsingDate: browsingDate.toDate(), minDate, includedDates: props.includedDates, }); const today = dayjs(); const firstDayOfMonth = browsingDate.startOf("month"); const isSecondWeekOver = today.isAfter(firstDayOfMonth.add(2, "week")); let days: (Dayjs | null)[] = []; const getPadding = (day: number) => (browsingDate.set("date", day).day() - weekStart + 7) % 7; const totalDays = daysInMonth(browsingDate); const showNextMonthDays = isSecondWeekOver && !isCompact; // Only apply end-of-month logic for main monthly view (not compact sidebar) if (showNextMonthDays) { const startDay = 8; const pad = getPadding(startDay); days = Array(pad).fill(null); for (let day = startDay; day <= totalDays; day++) { days.push(browsingDate.set("date", day)); } const remainingInRow = days.length % 7; const extraDays = (remainingInRow > 0 ? 7 - remainingInRow : 0) + 7; const nextMonth = browsingDate.add(1, "month"); // Add days starting from day 1 of next month for (let i = 0; i < extraDays; i++) { days.push(nextMonth.set("date", 1 + i)); } } else { // Traditional calendar grid logic for compact sidebar or early in month const pad = getPadding(1); days = Array(pad).fill(null); for (let day = 1; day <= totalDays; day++) { days.push(browsingDate.set("date", day)); } } const [selectedDatesAndTimes] = useBookerStoreContext((state) => [state.selectedDatesAndTimes], shallow); const isActive = (day: dayjs.Dayjs) => { // for selecting a range of dates if (Array.isArray(selected)) { return Array.isArray(selected) && selected?.some((e) => yyyymmdd(e) === yyyymmdd(day)); } if (selected && yyyymmdd(selected) === yyyymmdd(day)) { return true; } // for selecting multiple dates for an event if ( eventSlug && selectedDatesAndTimes && selectedDatesAndTimes[eventSlug as string] && Object.keys(selectedDatesAndTimes[eventSlug as string]).length > 0 ) { return Object.keys(selectedDatesAndTimes[eventSlug as string]).some((date) => { return yyyymmdd(dayjs(date)) === yyyymmdd(day); }); } return false; }; const daysToRenderForTheMonth = days.map((day) => { if (!day) return { day: null, disabled: true }; const dateKey = yyyymmdd(day); const daySlots = slots?.[dateKey] || []; const oooInfo = daySlots.find((slot) => slot.away) || null; const isNextMonth = day.month() !== browsingDate.month(); const isFirstDayOfNextMonth = isSecondWeekOver && !isCompact && isNextMonth && day.date() === 1; const included = includedDates?.includes(dateKey); const excluded = excludedDates.includes(dateKey); const hasAvailableSlots = daySlots.some((slot) => !slot.away); const isOOOAllDay = daySlots.length > 0 && daySlots.every((slot) => slot.away); const away = isOOOAllDay; // OOO dates are selectable only if there's a redirect user OR the note is public const oooIsSelectable = oooInfo?.toUser || oooInfo?.showNotePublicly; const disabled = away ? !oooIsSelectable : isNextMonth ? !hasAvailableSlots : !included || excluded; return { day, disabled, away, emoji: oooInfo?.emoji, isFirstDayOfNextMonth, }; }); /** * Takes care of selecting a valid date in the month if the selected date is not available in the month */ const useHandleInitialDateSelection = () => { // Don't auto-select date when slots view on small screen is enabled on mobile if (slotsViewOnSmallScreen) { return; } // Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment if (selected instanceof Array) { return; } const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day; const isSelectedDateAvailable = selected ? daysToRenderForTheMonth.some(({ day, disabled }) => { if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true; }) : false; if (!isSelectedDateAvailable && firstAvailableDateOfTheMonth) { // If selected date not available in the month, select the first available date of the month const shouldOmitUpdatingParams = selected?.isValid() ? false : true; // In case a date is selected and it is not available, then we have to change search params props.onChange(firstAvailableDateOfTheMonth, shouldOmitUpdatingParams); } if (isSelectedDateAvailable) { props.onChange(dayjs(selected), true); } if (!firstAvailableDateOfTheMonth) { props.onChange(null); } }; useEffect(useHandleInitialDateSelection); return ( <> {daysToRenderForTheMonth.map(({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => (
{day === null ? (
) : props.isLoading ? ( ) : ( { props.onChange(day); props?.scrollToTimeSlots?.(); }} disabled={disabled} active={isActive(day)} away={away} emoji={emoji} showMonthTooltip={showNextMonthDays && !disabled && day.month() !== browsingDate.month()} isFirstDayOfNextMonth={isFirstDayOfNextMonth} /> )}
))} {!props.isLoading && !isBookingInPast && includedDates && includedDates?.length === 0 && showNoAvailabilityDialog && ( )} ); }; const DatePicker = ({ weekStart = 0, className, locale, selected, onMonthChange, slots, customClassNames, includedDates, periodData = { periodStartDate: null, periodEndDate: null, periodCountCalendarDays: null, periodDays: null, periodType: "UNLIMITED", }, isCompact, showNoAvailabilityDialog, ...passThroughProps }: DatePickerProps & Partial> & { customClassNames?: { datePickerTitle?: string; datePickerDays?: string; datePickersDates?: string; datePickerDatesActive?: string; datePickerToggle?: string; }; scrollToTimeSlots?: () => void; }) => { const minDate = passThroughProps.minDate; const rawBrowsingDate = passThroughProps.browsingDate || dayjs().startOf("month"); const browsingDate = minDate && rawBrowsingDate.valueOf() < minDate.valueOf() ? dayjs(minDate) : rawBrowsingDate; const { i18n, t } = useLocale(); const bookingData = useBookerStoreContext((state) => state.bookingData); const isBookingInPast = bookingData ? new Date(bookingData.endTime) < new Date() : false; const changeMonth = (newMonth: number) => { if (onMonthChange) { onMonthChange(browsingDate.add(newMonth, "month")); } }; const month = browsingDate ? new Intl.DateTimeFormat(i18n.language, { month: "long" }).format( new Date(browsingDate.year(), browsingDate.month()) ) : null; return (
{browsingDate ? ( ) : ( )}
{weekdayNames(locale, weekStart, "short").map((weekDay) => (
{weekDay}
))}
changeMonth(+1)} slots={slots} includedDates={includedDates} isBookingInPast={isBookingInPast} periodData={periodData} isCompact={isCompact} showNoAvailabilityDialog={showNoAvailabilityDialog} />
); }; export { DatePicker, Day }; export default DatePicker;