cal.pub0.org/packages/features/calendars/DatePicker.tsx

346 lines
12 KiB
TypeScript

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 { useBookerStore } from "@calcom/features/bookings/Booker/store";
import classNames from "@calcom/lib/classNames";
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
import { Button, SkeletonText } from "@calcom/ui";
import { ChevronLeft, ChevronRight } from "@calcom/ui/components/icon";
import { ArrowRight } from "@calcom/ui/components/icon";
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) => 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?: Dayjs;
/** Furthest date selectable in the future, default = UNLIMITED */
maxDate?: Dayjs;
/** 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[] | null;
/** 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;
};
export const Day = ({
date,
active,
disabled,
...props
}: JSX.IntrinsicElements["button"] & {
active: boolean;
date: Dayjs;
}) => {
const { t } = useLocale();
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
return (
<button
type="button"
style={disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }}
className={classNames(
"disabled:text-bookinglighter absolute bottom-0 left-0 right-0 top-0 mx-auto w-full rounded-md border-2 border-transparent text-center text-sm font-medium disabled:cursor-default disabled:border-transparent disabled:font-light ",
active
? "bg-brand-default text-brand"
: !disabled
? " hover:border-brand-default text-emphasis bg-emphasis"
: "text-muted"
)}
data-testid="day"
data-disabled={disabled}
disabled={disabled}
{...props}>
{date.date()}
{date.isToday() && (
<span
className={classNames(
"bg-brand-default absolute left-1/2 top-1/2 flex h-[5px] w-[5px] -translate-x-1/2 translate-y-[8px] items-center justify-center rounded-full align-middle sm:translate-y-[12px]",
active && "invert"
)}>
<span className="sr-only">{t("today")}</span>
</span>
)}
</button>
);
};
const NoAvailabilityOverlay = ({
month,
nextMonthButton,
}: {
month: string | null;
nextMonthButton: () => void;
}) => {
const { t } = useLocale();
return (
<div className=" bg-muted border-subtle absolute left-1/2 top-40 -mt-10 w-max -translate-x-1/2 -translate-y-1/2 transform rounded-md border p-8 shadow-sm">
<h4 className="text-emphasis mb-4 font-medium">{t("no_availability_in_month", { month: month })}</h4>
<Button onClick={nextMonthButton} color="primary" EndIcon={ArrowRight}>
{t("view_next_month")}
</Button>
</div>
);
};
const Days = ({
minDate = dayjs.utc(),
excludedDates = [],
browsingDate,
weekStart,
DayComponent = Day,
selected,
month,
nextMonthButton,
eventSlug,
...props
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
browsingDate: Dayjs;
weekStart: number;
month: string | null;
nextMonthButton: () => void;
}) => {
// Create placeholder elements for empty days in first week
const weekdayOfFirst = browsingDate.date(1).day();
const currentDate = minDate.utcOffset(browsingDate.utcOffset());
const availableDates = (includedDates: string[] | undefined | null) => {
const dates = [];
const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate));
for (
let date = currentDate;
date.isBefore(lastDateOfMonth) || date.isSame(lastDateOfMonth, "day");
date = date.add(1, "day")
) {
// even if availableDates is given, filter out the passed included dates
if (includedDates && !includedDates.includes(yyyymmdd(date))) {
continue;
}
dates.push(yyyymmdd(date));
}
return dates;
};
const includedDates = currentDate.isSame(browsingDate, "month")
? availableDates(props.includedDates)
: props.includedDates;
const days: (Dayjs | null)[] = Array((weekdayOfFirst - weekStart + 7) % 7).fill(null);
for (let day = 1, dayCount = daysInMonth(browsingDate); day <= dayCount; day++) {
const date = browsingDate.set("date", day);
days.push(date);
}
const daysToRenderForTheMonth = days.map((day) => {
if (!day) return { day: null, disabled: true };
return {
day: day,
disabled:
(includedDates && !includedDates.includes(yyyymmdd(day))) || excludedDates.includes(yyyymmdd(day)),
};
});
useHandleInitialDateSelection({
daysToRenderForTheMonth,
selected,
onChange: props.onChange,
});
const [selectedDatesAndTimes] = useBookerStore((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;
};
return (
<>
{daysToRenderForTheMonth.map(({ day, disabled }, idx) => (
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
{day === null ? (
<div key={`e-${idx}`} />
) : props.isLoading ? (
<button
className="bg-muted text-muted absolute bottom-0 left-0 right-0 top-0 mx-auto flex w-full items-center justify-center rounded-sm border-transparent text-center font-medium opacity-50"
key={`e-${idx}`}
disabled>
<SkeletonText className="h-4 w-5" />
</button>
) : (
<DayComponent
date={day}
onClick={() => {
props.onChange(day);
}}
disabled={disabled}
active={isActive(day)}
/>
)}
</div>
))}
{!props.isLoading && includedDates && includedDates?.length === 0 && (
<NoAvailabilityOverlay month={month} nextMonthButton={nextMonthButton} />
)}
</>
);
};
const DatePicker = ({
weekStart = 0,
className,
locale,
selected,
onMonthChange,
...passThroughProps
}: DatePickerProps & Partial<React.ComponentProps<typeof Days>>) => {
const browsingDate = passThroughProps.browsingDate || dayjs().startOf("month");
const { i18n } = useLocale();
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 (
<div className={className}>
<div className="mb-1 flex items-center justify-between text-xl">
<span className="text-default w-1/2 text-base">
{browsingDate ? (
<>
<strong className="text-emphasis font-semibold">{month}</strong>{" "}
<span className="text-subtle font-medium">{browsingDate.format("YYYY")}</span>
</>
) : (
<SkeletonText className="h-8 w-24" />
)}
</span>
<div className="text-emphasis">
<div className="flex">
<Button
className={classNames(
"group p-1 opacity-70 hover:opacity-100 rtl:rotate-180",
!browsingDate.isAfter(dayjs()) &&
"disabled:text-bookinglighter hover:bg-background hover:opacity-70"
)}
onClick={() => changeMonth(-1)}
disabled={!browsingDate.isAfter(dayjs())}
data-testid="decrementMonth"
color="minimal"
variant="icon"
StartIcon={ChevronLeft}
/>
<Button
className="group p-1 opacity-70 hover:opacity-100 rtl:rotate-180"
onClick={() => changeMonth(+1)}
data-testid="incrementMonth"
color="minimal"
variant="icon"
StartIcon={ChevronRight}
/>
</div>
</div>
</div>
<div className="border-subtle mb-2 grid grid-cols-7 gap-4 border-b border-t text-center md:mb-0 md:border-0">
{weekdayNames(locale, weekStart, "short").map((weekDay) => (
<div key={weekDay} className="text-emphasis my-4 text-xs font-medium uppercase tracking-widest">
{weekDay}
</div>
))}
</div>
<div className="relative grid grid-cols-7 gap-1 text-center">
<Days
weekStart={weekStart}
selected={selected}
{...passThroughProps}
browsingDate={browsingDate}
month={month}
nextMonthButton={() => changeMonth(+1)}
/>
</div>
</div>
);
};
/**
* Takes care of selecting a valid date in the month if the selected date is not available in the month
*/
const useHandleInitialDateSelection = ({
daysToRenderForTheMonth,
selected,
onChange,
}: {
daysToRenderForTheMonth: { day: Dayjs | null; disabled: boolean }[];
selected: Dayjs | Dayjs[] | null | undefined;
onChange: (date: Dayjs | null) => void;
}) => {
// 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 (firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month
if (!isSelectedDateAvailable) {
onChange(firstAvailableDateOfTheMonth);
}
} else {
// No date is available and if we were asked to select something inform that it couldn't be selected. This would actually help in not showing the timeslots section(with No Time Available) when no date in the month is available
if (selected) {
onChange(null);
}
}
};
export default DatePicker;