From e9f3248fc09c1be7a84598df724786999870ac7d Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 15 Jun 2022 21:54:31 +0100 Subject: [PATCH] Feature/booking page refactor (#3035) * Extracted UI related logic on the DatePicker, stripped out all logic * wip * fixed small regression due to merge * Fix alignment of the chevrons * Added isToday dot, added onMonthChange so we can fetch this month slots * Added includedDates to inverse excludedDates * removed trpcState * Improvements to the state * All params are now dynamic * This builds the flat map so not all paths block on every new build * Added requiresConfirmation * Correctly take into account getFilteredTimes to make the calendar function * Rewritten team availability, seems to work * Circumvent i18n flicker by showing the loader instead * 'You can remove this code. Its not being used now' - Hariom * Nailed a persistent little bug, new Date() caused the current day to flicker on and off * TS fixes * Fix some eventType details in AvailableTimes * '5 / 6 Seats Available' instead of '6 / Seats Available' * More type fixes * Removed unrelated merge artifact * Use WEBAPP_URL instead of hardcoded * Next round of TS fixes * I believe this was mistyped * Temporarily disabled rescheduling 'this is when you originally scheduled', so removed dep * Sorting some dead code * This page has a lot of red, not all related to this PR * A PR to your PR (#3067) * Cleanup * Cleanup * Uses zod to parse params * Type fixes * Fixes ISR * E2E fixes * Disabled dynamic bookings until post v1.7 * More test fixes * Fixed border position (transparent border) to prevent dot from jumping - and possibly fix spacing * Disabled style nitpicks * Delete useSlots.ts Removed early design artifact * Unlock DatePicker locale * Adds mini spinner to DatePicker Co-authored-by: Peer Richelsen Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars --- .../web/components/booking/AvailableTimes.tsx | 60 +-- apps/web/components/booking/DatePicker.tsx | 10 +- .../booking/pages/AvailabilityPage.tsx | 409 +++++++++++------- apps/web/lib/hooks/useSlots.ts | 16 +- apps/web/lib/slots.ts | 12 +- apps/web/lib/types/schedule.ts | 8 - apps/web/pages/[user].tsx | 118 +++-- apps/web/pages/[user]/[type].tsx | 404 ++++++++--------- apps/web/pages/api/book/event.ts | 1 + apps/web/pages/d/[link]/[slug].tsx | 19 +- apps/web/pages/team/[slug]/[type].tsx | 2 +- apps/web/pages/team/[slug]/book.tsx | 2 +- .../playwright/dynamic-booking-pages.test.ts | 1 + apps/web/playwright/reschedule.test.ts | 1 + apps/web/server/routers/viewer.tsx | 4 +- apps/web/server/routers/viewer/slots.tsx | 256 +++++++++++ packages/core/getUserAvailability.ts | 60 +-- packages/lib/date-fns/index.ts | 6 + packages/lib/defaultEvents.ts | 34 +- packages/ui/booker/DatePicker.tsx | 200 +++++++++ yarn.lock | 2 +- 21 files changed, 1052 insertions(+), 573 deletions(-) create mode 100644 apps/web/server/routers/viewer/slots.tsx create mode 100644 packages/lib/date-fns/index.ts create mode 100644 packages/ui/booker/DatePicker.tsx diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index 94dd1c9789..68a0bfa734 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -1,66 +1,44 @@ -import { ExclamationIcon } from "@heroicons/react/solid"; import { SchedulingType } from "@prisma/client"; import dayjs, { Dayjs } from "dayjs"; import Link from "next/link"; import { useRouter } from "next/router"; -import React, { FC, useEffect, useState } from "react"; +import { FC, useEffect, useState } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { nameOfDay } from "@calcom/lib/weekday"; import classNames from "@lib/classNames"; import { timeZone } from "@lib/clock"; -import { useLocale } from "@lib/hooks/useLocale"; -import { useSlots } from "@lib/hooks/useSlots"; -import Loader from "@components/Loader"; +import type { Slot } from "@server/routers/viewer/slots"; type AvailableTimesProps = { timeFormat: string; - minimumBookingNotice: number; - beforeBufferTime: number; - afterBufferTime: number; eventTypeId: number; - eventLength: number; recurringCount: number | undefined; eventTypeSlug: string; - slotInterval: number | null; date: Dayjs; users: { username: string | null; }[]; schedulingType: SchedulingType | null; seatsPerTimeSlot?: number | null; + slots?: Slot[]; }; const AvailableTimes: FC = ({ + slots = [], date, - eventLength, eventTypeId, eventTypeSlug, - slotInterval, - minimumBookingNotice, recurringCount, timeFormat, - users, schedulingType, - beforeBufferTime, - afterBufferTime, seatsPerTimeSlot, }) => { const { t, i18n } = useLocale(); const router = useRouter(); const { rescheduleUid } = router.query; - const { slots, loading, error } = useSlots({ - date, - slotInterval, - eventLength, - schedulingType, - users, - minimumBookingNotice, - beforeBufferTime, - afterBufferTime, - eventTypeId, - }); const [brand, setBrand] = useState("#292929"); @@ -80,8 +58,7 @@ const AvailableTimes: FC = ({
- {!loading && - slots?.length > 0 && + {slots?.length > 0 && slots.map((slot) => { type BookingURL = { pathname: string; @@ -91,7 +68,7 @@ const AvailableTimes: FC = ({ pathname: "book", query: { ...router.query, - date: slot.time.format(), + date: dayjs(slot.time).format(), type: eventTypeId, slug: eventTypeSlug, /** Treat as recurring only when a count exist and it's not a rescheduling workflow */ @@ -113,7 +90,7 @@ const AvailableTimes: FC = ({ } return ( -
+
{/* Current there is no way to disable Next.js Links */} {seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
= ({ "text-primary-500 mb-2 block rounded-sm border bg-white py-4 font-medium opacity-25 dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 ", brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand" )}> - {slot.time.format(timeFormat)} + {dayjs(slot.time).tz(timeZone()).format(timeFormat)} {!!seatsPerTimeSlot &&

{t("booking_full")}

}
) : ( @@ -132,7 +109,7 @@ const AvailableTimes: FC = ({ brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand" )} data-testid="time"> - {dayjs.tz(slot.time, timeZone()).format(timeFormat)} + {dayjs(slot.time).tz(timeZone()).format(timeFormat)} {!!seatsPerTimeSlot && (

= ({

); })} - {!loading && !error && !slots.length && ( + {!slots.length && (

{t("all_booked_today")}

)} - - {loading && } - - {error && ( -
-
-
-
-
-

{t("slots_load_fail")}

-
-
-
- )}
); diff --git a/apps/web/components/booking/DatePicker.tsx b/apps/web/components/booking/DatePicker.tsx index 8a3398a9df..dff8255e75 100644 --- a/apps/web/components/booking/DatePicker.tsx +++ b/apps/web/components/booking/DatePicker.tsx @@ -249,15 +249,9 @@ function DatePicker({ day.disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles } } className={classNames( - "absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center", - "hover:border-brand hover:border dark:hover:border-white", - day.disabled - ? "text-bookinglighter cursor-default font-light hover:border-0" - : "font-medium", + "hover:border-brand disabled:text-bookinglighter absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center font-medium hover:border disabled:cursor-default disabled:font-light disabled:hover:border-0 dark:hover:border-white", date && date.isSame(browsingDate.date(day.date), "day") - ? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast" - : !day.disabled - ? " bg-gray-100 dark:bg-gray-600 dark:text-white" + ? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast disabled:bg-gray-100 disabled:dark:bg-gray-600 disabled:dark:text-white" : "" )} data-testid="day" diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 97caa287ce..7a91f5e89c 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -1,66 +1,70 @@ // Get router variables import { ArrowLeftIcon, - CalendarIcon, ChevronDownIcon, ChevronUpIcon, + ClipboardCheckIcon, ClockIcon, CreditCardIcon, GlobeIcon, InformationCircleIcon, LocationMarkerIcon, - ClipboardCheckIcon, RefreshIcon, VideoCameraIcon, } from "@heroicons/react/solid"; +import { EventType } from "@prisma/client"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useContracts } from "contexts/contractsContext"; -import dayjs, { Dayjs } from "dayjs"; +import dayjs from "dayjs"; import customParseFormat from "dayjs/plugin/customParseFormat"; import utc from "dayjs/plugin/utc"; import { TFunction } from "next-i18next"; import { useRouter } from "next/router"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { FormattedNumber, IntlProvider } from "react-intl"; import { AppStoreLocationType, LocationObject, LocationType } from "@calcom/app-store/locations"; import { - useEmbedStyles, - useIsEmbed, - useIsBackgroundTransparent, - sdkActionManager, useEmbedNonStylesConfig, + useEmbedStyles, + useIsBackgroundTransparent, + useIsEmbed, } from "@calcom/embed-core/embed-iframe"; import classNames from "@calcom/lib/classNames"; import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; +import { yyyymmdd } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { getRecurringFreq } from "@calcom/lib/recurringStrings"; import { localStorage } from "@calcom/lib/webstorage"; +import Loader from "@calcom/ui/Loader"; +import DatePicker from "@calcom/ui/booker/DatePicker"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally"; import useTheme from "@lib/hooks/useTheme"; import { isBrandingHidden } from "@lib/isBrandingHidden"; -import { parseDate } from "@lib/parseDate"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { detectBrowserTimeFormat } from "@lib/timeFormat"; +import { trpc } from "@lib/trpc"; import CustomBranding from "@components/CustomBranding"; import AvailableTimes from "@components/booking/AvailableTimes"; -import DatePicker from "@components/booking/DatePicker"; import TimeOptions from "@components/booking/TimeOptions"; import { HeadSeo } from "@components/seo/head-seo"; import AvatarGroup from "@components/ui/AvatarGroup"; import PoweredByCal from "@components/ui/PoweredByCal"; -import { AvailabilityPageProps } from "../../../pages/[user]/[type]"; -import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]"; +import type { Slot } from "@server/routers/viewer/slots"; + +import type { AvailabilityPageProps } from "../../../pages/[user]/[type]"; +import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]"; +import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]"; dayjs.extend(utc); dayjs.extend(customParseFormat); -type Props = AvailabilityTeamPageProps | AvailabilityPageProps; +type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps; export const locationKeyToString = (location: LocationObject, t: TFunction) => { switch (location.type) { @@ -91,27 +95,173 @@ export const locationKeyToString = (location: LocationObject, t: TFunction) => { } }; -const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => { +const GoBackToPreviousPage = ({ slug }: { slug: string }) => { const router = useRouter(); - const isEmbed = useIsEmbed(); - const { rescheduleUid } = router.query; - const { isReady, Theme } = useTheme(profile.theme); - const { t, i18n } = useLocale(); - const { contracts } = useContracts(); - const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker"); - const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; - const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; - const isBackgroundTransparent = useIsBackgroundTransparent(); - useExposePlanGlobally(plan); + const [previousPage, setPreviousPage] = useState(); useEffect(() => { - if (eventType.metadata.smartContractAddress) { - const eventOwner = eventType.users[0]; - if (!contracts[(eventType.metadata.smartContractAddress || null) as number]) - router.replace(`/${eventOwner.username}`); - } - }, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]); + setPreviousPage(document.referrer); + }, []); - const selectedDate = useMemo(() => { + return previousPage === `${WEBAPP_URL}/${slug}` ? ( +
+ router.back()} + /> +

Go Back

+
+ ) : ( + <> + ); +}; + +const useSlots = ({ + eventTypeId, + startTime, + endTime, +}: { + eventTypeId: number; + startTime: Date; + endTime: Date; +}) => { + const { data, isLoading } = trpc.useQuery([ + "viewer.slots.getSchedule", + { + eventTypeId, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }, + ]); + + return { slots: data?.slots || {}, isLoading }; +}; + +const SlotPicker = ({ + eventType, + timezoneDropdown, + timeFormat, + timeZone, + recurringEventCount, + seatsPerTimeSlot, + weekStart = 0, +}: { + eventType: Pick; + timezoneDropdown: JSX.Element; + timeFormat: string; + timeZone?: string; + seatsPerTimeSlot?: number; + recurringEventCount?: number; + weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6; +}) => { + const { selectedDate, setSelectedDate } = useDateSelected({ timeZone }); + + const { i18n } = useLocale(); + const [startDate, setStartDate] = useState(new Date()); + + useEffect(() => { + if (dayjs(selectedDate).startOf("month").isAfter(dayjs())) { + setStartDate(dayjs(selectedDate).startOf("month").toDate()); + } + }, [selectedDate]); + + const { slots, isLoading } = useSlots({ + eventTypeId: eventType.id, + startTime: startDate, + endTime: dayjs(startDate).endOf("month").toDate(), + }); + + const [times, setTimes] = useState([]); + + useEffect(() => { + if (selectedDate && slots[yyyymmdd(selectedDate)]) { + setTimes(slots[yyyymmdd(selectedDate)]); + } + }, [selectedDate, slots]); + + return ( + <> + slots[k].length > 0)} + selected={selectedDate} + onChange={setSelectedDate} + onMonthChange={setStartDate} + weekStart={weekStart} + /> + +
{timezoneDropdown}
+ + {selectedDate && ( + + )} + + ); +}; + +function TimezoneDropdown({ + onChangeTimeFormat, + onChangeTimeZone, +}: { + onChangeTimeFormat: (newTimeFormat: string) => void; + onChangeTimeZone: (newTimeZone: string) => void; +}) { + const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); + + useEffect(() => { + handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSelectTimeZone = (newTimeZone: string) => { + timeZone(newTimeZone); + onChangeTimeZone(newTimeZone); + setIsTimeOptionsOpen(false); + }; + + const handleToggle24hClock = (is24hClock: boolean) => { + onChangeTimeFormat(is24hClock ? "HH:mm" : "h:mma"); + }; + + return ( + + + + {timeZone()} + {isTimeOptionsOpen ? ( + + ) : ( + + )} + + + + + + ); +} + +const useDateSelected = ({ timeZone }: { timeZone?: string }) => { + const router = useRouter(); + const [selectedDate, _setSelectedDate] = useState(); + + useEffect(() => { const dateString = asStringOrNull(router.query.date); if (dateString) { const offsetString = dateString.substr(11, 14); // hhmm @@ -126,24 +276,66 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage (offsetMinute !== "" ? parseInt(offsetMinute) : 0)); const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true); - return date.isValid() ? date : null; + if (date.isValid()) { + setSelectedDate(date.toDate()); + } } - return null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const setSelectedDate = (newDate: Date) => { + router.replace( + { + query: { + ...router.query, + date: dayjs(newDate).tz(timeZone, true).format("YYYY-MM-DDZZ"), + }, + }, + undefined, + { shallow: true } + ); + _setSelectedDate(newDate); + }; + + return { selectedDate, setSelectedDate }; +}; + +const AvailabilityPage = ({ profile, eventType }: Props) => { + const router = useRouter(); + const isEmbed = useIsEmbed(); + const { rescheduleUid } = router.query; + const { isReady, Theme } = useTheme(profile.theme); + const { t, i18n } = useLocale(); + const { contracts } = useContracts(); + const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker"); + const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; + const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; + const isBackgroundTransparent = useIsBackgroundTransparent(); + + const [timeZone, setTimeZone] = useState(); + const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat); + const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState(); + + useEffect(() => { + setIsAvailableTimesVisible(!!router.query.date); }, [router.query.date]); - if (selectedDate) { - // Let iframe take the width available due to increase in max-width - sdkActionManager?.fire("__refreshWidth", {}); - } - const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); - const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat); + // TODO: Improve this; + useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO"); + + // TODO: this needs to be extracted elsewhere + useEffect(() => { + if (eventType.metadata.smartContractAddress) { + const eventOwner = eventType.users[0]; + if (!contracts[(eventType.metadata.smartContractAddress || null) as number]) + router.replace(`/${eventOwner.username}`); + } + }, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]); + const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count); const telemetry = useTelemetry(); - useEffect(() => { - handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true"); - if (top !== window) { //page_view will be collected automatically by _middleware.ts telemetry.event( @@ -153,45 +345,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage } }, [telemetry]); - const changeDate = useCallback( - (newDate: Dayjs) => { - router.replace( - { - query: { - ...router.query, - date: newDate.tz(timeZone(), true).format("YYYY-MM-DDZZ"), - }, - }, - undefined, - { shallow: true } - ); - }, - [router] - ); - - useEffect(() => { - if ( - selectedDate != null && - selectedDate?.utcOffset() !== selectedDate.clone().utcOffset(0).tz(timeZone()).utcOffset() - ) { - changeDate(selectedDate.tz(timeZone(), true)); - } - }, [selectedDate, changeDate]); - - const handleSelectTimeZone = (selectedTimeZone: string): void => { - timeZone(selectedTimeZone); - if (selectedDate) { - changeDate(selectedDate.tz(selectedTimeZone, true)); - } - setIsTimeOptionsOpen(false); - }; - - const handleToggle24hClock = (is24hClock: boolean) => { - setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); - }; - // Recurring event sidebar requires more space - const maxWidth = selectedDate + const maxWidth = isAvailableTimesVisible ? recurringEventCount ? "max-w-6xl" : "max-w-5xl" @@ -199,6 +354,14 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage ? "max-w-4xl" : "max-w-3xl"; + if (Object.keys(i18n).length === 0) { + return ; + } + + const timezoneDropdown = ( + + ); + return ( <> @@ -340,10 +503,10 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage )} - + {timezoneDropdown}
- {booking?.startTime && rescheduleUid && ( + {/* Temp disabled booking?.startTime && rescheduleUid && (

- )} + )*/}
@@ -368,7 +531,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage

)} - + {timezoneDropdown}
- {previousPage === `${WEBAPP_URL}/${profile.slug}` && ( -
- router.back()} - /> -

Go Back

-
- )} - {booking?.startTime && rescheduleUid && ( + + + {/* Temporarily disabled - booking?.startTime && rescheduleUid && (

- )} + )*/} - - - -
- -
- - {selectedDate && ( - - )} )} @@ -553,25 +681,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage ); - - function TimezoneDropdown() { - return ( - - - - {timeZone()} - {isTimeOptionsOpen ? ( - - ) : ( - - )} - - - - - - ); - } }; export default AvailabilityPage; diff --git a/apps/web/lib/hooks/useSlots.ts b/apps/web/lib/hooks/useSlots.ts index d2ea3d0bc5..9119ba1a39 100644 --- a/apps/web/lib/hooks/useSlots.ts +++ b/apps/web/lib/hooks/useSlots.ts @@ -5,8 +5,10 @@ import utc from "dayjs/plugin/utc"; import { stringify } from "querystring"; import { useEffect, useState } from "react"; +import type { CurrentSeats } from "@calcom/core/getUserAvailability"; + import getSlots from "@lib/slots"; -import { CurrentSeats, TimeRange, WorkingHours } from "@lib/types/schedule"; +import type { TimeRange, WorkingHours } from "@lib/types/schedule"; dayjs.extend(isBetween); dayjs.extend(utc); @@ -15,7 +17,7 @@ type AvailabilityUserResponse = { busy: TimeRange[]; timeZone: string; workingHours: WorkingHours[]; - currentSeats?: CurrentSeats[]; + currentSeats?: CurrentSeats; }; type Slot = { @@ -43,7 +45,7 @@ type getFilteredTimesProps = { eventLength: number; beforeBufferTime: number; afterBufferTime: number; - currentSeats?: CurrentSeats[]; + currentSeats?: CurrentSeats; }; export const getFilteredTimes = (props: getFilteredTimesProps) => { @@ -61,7 +63,7 @@ export const getFilteredTimes = (props: getFilteredTimesProps) => { const slotEndTime = times[i].add(eventLength, "minutes"); const slotStartTimeWithBeforeBuffer = times[i].subtract(beforeBufferTime, "minutes"); // If the event has seats then see if there is already a booking (want to show full bookings as well) - if (currentSeats?.some((booking) => booking.startTime === slotStartTime.toISOString())) { + if (currentSeats?.some((booking) => booking.startTime === slotStartTime.toDate())) { break; } busy.every((busyTime): boolean => { @@ -155,12 +157,12 @@ export const useSlots = (props: UseSlotsProps) => { time, users: [user], // Conditionally add the attendees and booking id to slots object if there is already a booking during that time - ...(currentSeats?.some((booking) => booking.startTime === time.toISOString()) && { + ...(currentSeats?.some((booking) => booking.startTime === time.toDate()) && { attendees: - currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toISOString())]._count + currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toDate())]._count .attendees, bookingUid: - currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toISOString())].uid, + currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toDate())].uid, }), })); }; diff --git a/apps/web/lib/slots.ts b/apps/web/lib/slots.ts index fb8ac14bed..f7011edf47 100644 --- a/apps/web/lib/slots.ts +++ b/apps/web/lib/slots.ts @@ -4,7 +4,7 @@ import isToday from "dayjs/plugin/isToday"; import utc from "dayjs/plugin/utc"; import { getWorkingHours } from "./availability"; -import { WorkingHours, CurrentSeats } from "./types/schedule"; +import { WorkingHours } from "./types/schedule"; dayjs.extend(isToday); dayjs.extend(utc); @@ -16,7 +16,6 @@ export type GetSlots = { workingHours: WorkingHours[]; minimumBookingNotice: number; eventLength: number; - currentSeats?: CurrentSeats[]; }; export type WorkingHoursTimeFrame = { startTime: number; endTime: number }; @@ -43,14 +42,7 @@ const splitAvailableTime = ( return result; }; -const getSlots = ({ - inviteeDate, - frequency, - minimumBookingNotice, - workingHours, - eventLength, - currentSeats, -}: GetSlots) => { +const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours, eventLength }: GetSlots) => { // current date in invitee tz const startDate = dayjs().add(minimumBookingNotice, "minute"); const startOfDay = dayjs.utc().startOf("day"); diff --git a/apps/web/lib/types/schedule.ts b/apps/web/lib/types/schedule.ts index 43537957f3..ba5e74b45e 100644 --- a/apps/web/lib/types/schedule.ts +++ b/apps/web/lib/types/schedule.ts @@ -16,11 +16,3 @@ export type WorkingHours = { startTime: number; endTime: number; }; - -export type CurrentSeats = { - uid: string; - startTime: string; - _count: { - attendees: number; - }; -}; diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 3572bbc1f5..424b8b7282 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -46,71 +46,53 @@ interface EvtsToVerify { } export default function User(props: inferSSRProps) { - const { users, profile } = props; + const { users, profile, eventTypes, isDynamicGroup, dynamicNames, dynamicUsernames, isSingleUser } = props; const [user] = users; //To be used when we only have a single user, not dynamic group const { Theme } = useTheme(user.theme); const { t } = useLocale(); const router = useRouter(); - const isSingleUser = props.users.length === 1; - const isDynamicGroup = props.users.length > 1; - const dynamicNames = isDynamicGroup - ? props.users.map((user) => { - return user.name || ""; - }) - : []; - const dynamicUsernames = isDynamicGroup - ? props.users.map((user) => { - return user.username || ""; - }) - : []; - const eventTypes = isDynamicGroup - ? defaultEvents.map((event) => { - event.description = getDynamicEventDescription(dynamicUsernames, event.slug); - return event; - }) - : props.eventTypes; - const groupEventTypes = props.users.some((user) => { - return !user.allowDynamicBooking; - }) ? ( -
-
-
-

{" " + t("unavailable")}

-

{t("user_dynamic_booking_disabled") as string}

+ + const groupEventTypes = + /* props.users.some((user) => !user.allowDynamicBooking) TODO: Re-enable after v1.7 launch */ true ? ( +
+
+
+

{" " + t("unavailable")}

+

{t("user_dynamic_booking_disabled") as string}

+
-
- ) : ( - - ); + ) : ( + + ); const isEmbed = useIsEmbed(); const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem"); const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; @@ -376,6 +358,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => : false, })); + const isSingleUser = users.length === 1; + const dynamicUsernames = isDynamicGroup + ? users.map((user) => { + return user.username || ""; + }) + : []; + return { props: { users, @@ -383,8 +372,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => user: { emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), }, - eventTypes, + eventTypes: isDynamicGroup + ? defaultEvents.map((event) => { + event.description = getDynamicEventDescription(dynamicUsernames, event.slug); + return event; + }) + : eventTypes, trpcState: ssr.dehydrate(), + isDynamicGroup, + dynamicNames, + dynamicUsernames, + isSingleUser, }, }; }; diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index e895112694..cccd187d7e 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -1,27 +1,24 @@ -import { Prisma, UserPlan } from "@prisma/client"; -import { GetServerSidePropsContext } from "next"; +import { UserPlan } from "@prisma/client"; +import { GetStaticPropsContext } from "next"; import { JSONObject } from "superjson/dist/types"; +import { z } from "zod"; import { locationHiddenFilter, LocationObject } from "@calcom/app-store/locations"; -import { parseRecurringEvent } from "@calcom/lib"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { availiblityPageEventTypeSelect } from "@calcom/prisma/selects"; +import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; -import { asStringOrNull } from "@lib/asStringOrNull"; -import { getWorkingHours } from "@lib/availability"; -import getBooking, { GetBookingType } from "@lib/getBooking"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import AvailabilityPage from "@components/booking/pages/AvailabilityPage"; -import { ssrInit } from "@server/lib/ssr"; - -export type AvailabilityPageProps = inferSSRProps; +export type AvailabilityPageProps = inferSSRProps; export default function Type(props: AvailabilityPageProps) { const { t } = useLocale(); + return props.away ? (
@@ -37,7 +34,7 @@ export default function Type(props: AvailabilityPageProps) {
- ) : props.isDynamicGroup && !props.profile.allowDynamicBooking ? ( + ) : props.isDynamic /* && !props.profile.allowDynamicBooking TODO: Re-enable after v1.7 launch */ ? (
@@ -57,21 +54,115 @@ export default function Type(props: AvailabilityPageProps) { ); } -export const getServerSideProps = async (context: GetServerSidePropsContext) => { - const ssr = await ssrInit(context); - // get query params and typecast them to string - // (would be even better to assert them instead of typecasting) - const usernameList = getUsernameList(context.query.user as string); +async function getUserPageProps({ username, slug }: { username: string; slug: string }) { + const user = await prisma.user.findUnique({ + where: { + username, + }, + select: { + id: true, + away: true, + plan: true, + eventTypes: { + // Order is important to ensure that given a slug if there are duplicates, we choose the same event type consistently when showing in event-types list UI(in terms of ordering and disabled event types) + // TODO: If we can ensure that there are no duplicates for a [slug, userId] combination in existing data, this requirement might be avoided. + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + select: { + title: true, + slug: true, + recurringEvent: true, + length: true, + locations: true, + id: true, + description: true, + price: true, + currency: true, + requiresConfirmation: true, + schedulingType: true, + metadata: true, + seatsPerTimeSlot: true, + users: { + select: { + name: true, + username: true, + hideBranding: true, + brandColor: true, + darkBrandColor: true, + theme: true, + plan: true, + allowDynamicBooking: true, + timeZone: true, + }, + }, + }, + }, + }, + }); - const userParam = asStringOrNull(context.query.user); - const typeParam = asStringOrNull(context.query.type); - const dateParam = asStringOrNull(context.query.date); - const rescheduleUid = asStringOrNull(context.query.rescheduleUid); - - if (!userParam || !typeParam) { - throw new Error(`File is not named [type]/[user]`); + if (!user) { + return { + notFound: true, + }; } + const eventType = user.eventTypes.find((et, i) => + user.plan === UserPlan.FREE ? i === 0 && et.slug === slug : et.slug === slug + ); + + if (!eventType) { + return { + notFound: true, + }; + } + + const locations = eventType.locations ? (eventType.locations as LocationObject[]) : []; + + const eventTypeObject = Object.assign({}, eventType, { + metadata: (eventType.metadata || {}) as JSONObject, + recurringEvent: parseRecurringEvent(eventType.recurringEvent), + locations: locationHiddenFilter(locations), + users: eventType.users.map((user) => { + return { + name: user.name, + username: user.username, + hideBranding: user.hideBranding, + plan: user.plan, + timeZone: user.timeZone, + }; + }), + }); + + return { + props: { + eventType: eventTypeObject, + profile: { + ...eventType.users[0], + slug: `${eventType.users[0].username}/${eventType.slug}`, + image: `${WEBAPP_URL}/${eventType.users[0].username}/avatar.png`, + }, + away: user?.away, + isDynamic: false, + }, + revalidate: 10, // seconds + }; +} + +async function getDynamicGroupPageProps({ + usernameList, + length, +}: { + usernameList: string[]; + length: number; +}) { + const eventType = getDefaultEvent("" + length); + const users = await prisma.user.findMany({ where: { username: { @@ -105,226 +196,95 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, theme: true, plan: true, - eventTypes: { - where: { - AND: [ - { - slug: typeParam, - }, - { - teamId: null, - }, - ], - }, - // Order is important to ensure that given a slug if there are duplicates, we choose the same event type consistently when showing in event-types list UI(in terms of ordering and disabled event types) - // TODO: If we can ensure that there are no duplicates for a [slug, userId] combination in existing data, this requirement might be avoided. - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - select: { - ...availiblityPageEventTypeSelect, - users: { - select: { - id: false, - avatar: true, - name: true, - username: true, - hideBranding: true, - plan: true, - timeZone: true, - }, - }, - }, - }, }, }); - if (!users || !users.length) { + if (!users.length) { return { notFound: true, }; } - const [user] = users; //to be used when dealing with single user, not dynamic group - const isSingleUser = users.length === 1; - const isDynamicGroup = users.length > 1; - if (isSingleUser && user.eventTypes.length !== 1) { - const eventTypeBackwardsCompat = await prisma.eventType.findFirst({ - where: { - AND: [ - { - userId: user.id, - }, - { - slug: typeParam, - }, - ], - }, - select: { - ...availiblityPageEventTypeSelect, - users: { - select: { - id: false, - avatar: true, - name: true, - username: true, - hideBranding: true, - plan: true, - timeZone: true, - }, - }, - }, - }); - - if (!eventTypeBackwardsCompat) { - return { - notFound: true, - }; - } - - eventTypeBackwardsCompat.users.push({ - avatar: user.avatar, - name: user.name, - username: user.username, - hideBranding: user.hideBranding, - plan: user.plan, - timeZone: user.timeZone, - }); - - user.eventTypes.push(eventTypeBackwardsCompat); - } - let [eventType] = user.eventTypes; - if (isDynamicGroup) { - eventType = getDefaultEvent(typeParam); - eventType["users"] = users.map((user) => { - return { - avatar: user.avatar as string, - name: user.name as string, - username: user.username as string, - hideBranding: user.hideBranding, - plan: user.plan, - timeZone: user.timeZone as string, - }; - }); - } - - // check this is the first event for free user - if (isSingleUser && user.plan === UserPlan.FREE) { - const firstEventType = await prisma.eventType.findFirst({ - where: { - OR: [ - { - userId: user.id, - }, - { - users: { - some: { - id: user.id, - }, - }, - }, - ], - }, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - select: { - id: true, - }, - }); - if (firstEventType?.id !== eventType.id) { - return { - notFound: true, - } as const; - } - } const locations = eventType.locations ? (eventType.locations as LocationObject[]) : []; const eventTypeObject = Object.assign({}, eventType, { metadata: (eventType.metadata || {}) as JSONObject, - periodStartDate: eventType.periodStartDate?.toString() ?? null, - periodEndDate: eventType.periodEndDate?.toString() ?? null, recurringEvent: parseRecurringEvent(eventType.recurringEvent), locations: locationHiddenFilter(locations), + users: users.map((user) => { + return { + name: user.name, + username: user.username, + hideBranding: user.hideBranding, + plan: user.plan, + timeZone: user.timeZone, + }; + }), }); - const schedule = eventType.schedule - ? { ...eventType.schedule } - : { - ...user.schedules.filter( - (schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId - )[0], - }; + const dynamicNames = users.map((user) => { + return user.name || ""; + }); - const timeZone = isDynamicGroup ? undefined : schedule.timeZone || eventType.timeZone || user.timeZone; - - const workingHours = getWorkingHours( - { - timeZone, - }, - isDynamicGroup - ? eventType.availability || undefined - : schedule.availability || (eventType.availability.length ? eventType.availability : user.availability) - ); - eventTypeObject.schedule = null; - eventTypeObject.availability = []; - - let booking: GetBookingType | null = null; - if (rescheduleUid) { - booking = await getBooking(prisma, rescheduleUid); - } - - const dynamicNames = isDynamicGroup - ? users.map((user) => { - return user.name || ""; - }) - : []; - - const profile = isDynamicGroup - ? { - name: getGroupName(dynamicNames), - image: null, - slug: typeParam, - theme: null, - weekStart: "Sunday", - brandColor: "", - darkBrandColor: "", - allowDynamicBooking: !users.some((user) => { - return !user.allowDynamicBooking; - }), - } - : { - name: user.name || user.username, - image: user.avatar, - slug: user.username, - theme: user.theme, - weekStart: user.weekStart, - brandColor: user.brandColor, - darkBrandColor: user.darkBrandColor, - }; + const profile = { + name: getGroupName(dynamicNames), + image: null, + slug: "" + length, + theme: null as string | null, + weekStart: "Sunday", + brandColor: "", + darkBrandColor: "", + allowDynamicBooking: !users.some((user) => { + return !user.allowDynamicBooking; + }), + }; return { props: { - away: user.away, - isDynamicGroup, - profile, - plan: user.plan, - date: dateParam, eventType: eventTypeObject, - workingHours, - trpcState: ssr.dehydrate(), - previousPage: context.req.headers.referer ?? null, - booking, + profile, + isDynamic: true, + away: false, }, + revalidate: 10, // seconds }; +} + +const paramsSchema = z.object({ type: z.string(), user: z.string() }); + +export const getStaticProps = async (context: GetStaticPropsContext) => { + const { type: typeParam, user: userParam } = paramsSchema.parse(context.params); + + // dynamic groups are not generated at build time, but otherwise are probably cached until infinity. + const isDynamicGroup = userParam.includes("+"); + if (isDynamicGroup) { + return await getDynamicGroupPageProps({ + usernameList: getUsernameList(userParam), + length: parseInt(typeParam), + }); + } else { + return await getUserPageProps({ username: userParam, slug: typeParam }); + } +}; + +export const getStaticPaths = async () => { + const users = await prisma.user.findMany({ + select: { + username: true, + eventTypes: { + where: { + teamId: null, + }, + select: { + slug: true, + }, + }, + }, + }); + + const paths = users?.flatMap((user) => + user.eventTypes.flatMap((eventType) => `/${user.username}/${eventType.slug}`) + ); + + return { paths, fallback: "blocking" }; }; diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index ba2f2828a0..ec22a0e0db 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -159,6 +159,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => { id: eventTypeId, }, select: { + id: true, users: userSelect, team: { select: { diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index f5591695e3..9ddd5d942e 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -1,11 +1,10 @@ -import { Prisma } from "@prisma/client"; import { GetServerSidePropsContext } from "next"; import { JSONObject } from "superjson/dist/types"; +import { z } from "zod"; import { parseRecurringEvent } from "@calcom/lib"; import { availiblityPageEventTypeSelect } from "@calcom/prisma"; -import { asStringOrNull } from "@lib/asStringOrNull"; import { getWorkingHours } from "@lib/availability"; import { GetBookingType } from "@lib/getBooking"; import { locationHiddenFilter, LocationObject } from "@lib/location"; @@ -16,17 +15,21 @@ import AvailabilityPage from "@components/booking/pages/AvailabilityPage"; import { ssrInit } from "@server/lib/ssr"; -export type AvailabilityPageProps = inferSSRProps; +export type DynamicAvailabilityPageProps = inferSSRProps; -export default function Type(props: AvailabilityPageProps) { +export default function Type(props: DynamicAvailabilityPageProps) { return ; } +const querySchema = z.object({ + link: z.string().optional().default(""), + slug: z.string().optional().default(""), + date: z.union([z.string(), z.null()]).optional().default(null), +}); + export const getServerSideProps = async (context: GetServerSidePropsContext) => { const ssr = await ssrInit(context); - const link = asStringOrNull(context.query.link) || ""; - const slug = asStringOrNull(context.query.slug) || ""; - const dateParam = asStringOrNull(context.query.date); + const { link, slug, date } = querySchema.parse(context.query); const hashedLink = await prisma.hashedLink.findUnique({ where: { @@ -140,7 +143,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => isDynamicGroup: false, profile, plan: user.plan, - date: dateParam, + date, eventType: eventTypeObject, workingHours, trpcState: ssr.dehydrate(), diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 4fae6c83ea..d1e0e2d566 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -135,7 +135,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => name: team.name || team.slug, slug: team.slug, image: team.logo, - theme: null, + theme: null as string | null, weekStart: "Sunday", brandColor: "" /* TODO: Add a way to set a brand color for Teams */, darkBrandColor: "" /* TODO: Add a way to set a brand color for Teams */, diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index d09e26bfde..5ea220205a 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -109,7 +109,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // FIXME: This slug is used as username on success page which is wrong. This is correctly set as username for user booking. slug: "team/" + eventTypeObject.slug, image: eventTypeObject.team?.logo || null, - theme: null /* Teams don't have a theme, and `BookingPage` uses it */, + theme: null as string | null /* Teams don't have a theme, and `BookingPage` uses it */, brandColor: null /* Teams don't have a brandColor, and `BookingPage` uses it */, darkBrandColor: null /* Teams don't have a darkBrandColor, and `BookingPage` uses it */, eventName: null, diff --git a/apps/web/playwright/dynamic-booking-pages.test.ts b/apps/web/playwright/dynamic-booking-pages.test.ts index b57b5cb3b9..8dc7861582 100644 --- a/apps/web/playwright/dynamic-booking-pages.test.ts +++ b/apps/web/playwright/dynamic-booking-pages.test.ts @@ -11,6 +11,7 @@ import { test.describe.configure({ mode: "parallel" }); test.describe("dynamic booking", () => { + test.skip(true, "TODO: Re-enable after v1.7 launch"); test.beforeEach(async ({ page, users }) => { const pro = await users.create(); await pro.login(); diff --git a/apps/web/playwright/reschedule.test.ts b/apps/web/playwright/reschedule.test.ts index 15ef383890..17aacec459 100644 --- a/apps/web/playwright/reschedule.test.ts +++ b/apps/web/playwright/reschedule.test.ts @@ -46,6 +46,7 @@ test.describe("Reschedule Tests", async () => { }); test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => { + test.skip(true, "TODO: Re-enable after v1.7 launch"); const user = await users.create(); const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, { status: BookingStatus.CANCELLED, diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index e03b4a9e49..30bfba42d8 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -31,6 +31,7 @@ import { apiKeysRouter } from "@server/routers/viewer/apiKeys"; import { availabilityRouter } from "@server/routers/viewer/availability"; import { bookingsRouter } from "@server/routers/viewer/bookings"; import { eventTypesRouter } from "@server/routers/viewer/eventTypes"; +import { slotsRouter } from "@server/routers/viewer/slots"; import { TRPCError } from "@trpc/server"; import { createProtectedRouter, createRouter } from "../createRouter"; @@ -950,4 +951,5 @@ export const viewerRouter = createRouter() .merge("availability.", availabilityRouter) .merge("teams.", viewerTeamsRouter) .merge("webhook.", webhookRouter) - .merge("apiKeys.", apiKeysRouter); + .merge("apiKeys.", apiKeysRouter) + .merge("slots.", slotsRouter); diff --git a/apps/web/server/routers/viewer/slots.tsx b/apps/web/server/routers/viewer/slots.tsx new file mode 100644 index 0000000000..e9469d713a --- /dev/null +++ b/apps/web/server/routers/viewer/slots.tsx @@ -0,0 +1,256 @@ +import { SchedulingType } from "@prisma/client"; +import dayjs, { Dayjs } from "dayjs"; +import { z } from "zod"; + +import type { CurrentSeats } from "@calcom/core/getUserAvailability"; +import { getUserAvailability } from "@calcom/core/getUserAvailability"; +import { yyyymmdd } from "@calcom/lib/date-fns"; +import { availabilityUserSelect } from "@calcom/prisma"; +import { stringToDayjs } from "@calcom/prisma/zod-utils"; +import { TimeRange, WorkingHours } from "@calcom/types/schedule"; + +import getSlots from "@lib/slots"; + +import { createRouter } from "@server/createRouter"; +import { TRPCError } from "@trpc/server"; + +const getScheduleSchema = z + .object({ + // startTime ISOString + startTime: stringToDayjs, + // endTime ISOString + endTime: stringToDayjs, + // Event type ID + eventTypeId: z.number().optional(), + // or list of users (for dynamic events) + usernameList: z.array(z.string()).optional(), + }) + .refine( + (data) => !!data.eventTypeId || !!data.usernameList, + "Either usernameList or eventTypeId should be filled in." + ); + +export type Slot = { + time: string; + attendees?: number; + bookingUid?: string; + users?: string[]; +}; + +const checkForAvailability = ({ + time, + busy, + workingHours, + eventLength, + beforeBufferTime, + afterBufferTime, + currentSeats, +}: { + time: Dayjs; + busy: (TimeRange | { start: string; end: string })[]; + workingHours: WorkingHours[]; + eventLength: number; + beforeBufferTime: number; + afterBufferTime: number; + currentSeats?: CurrentSeats; +}) => { + if ( + !workingHours.every((workingHour) => { + if (!workingHour.days.includes(time.day())) { + return false; + } + if ( + !time.isBetween( + time.utc().startOf("day").add(workingHour.startTime, "minutes"), + time.utc().startOf("day").add(workingHour.endTime, "minutes"), + null, + "[)" + ) + ) { + return false; + } + return true; + }) + ) { + return false; + } + + if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) { + return true; + } + + const slotEndTime = time.add(eventLength, "minutes"); + const slotStartTimeWithBeforeBuffer = time.subtract(beforeBufferTime, "minutes"); + const slotEndTimeWithAfterBuffer = time.add(eventLength + afterBufferTime, "minutes"); + + return busy.every((busyTime): boolean => { + const startTime = dayjs(busyTime.start); + const endTime = dayjs(busyTime.end); + // Check if start times are the same + if (time.isBetween(startTime, endTime, null, "[)")) { + return false; + } + // Check if slot end time is between start and end time + else if (slotEndTime.isBetween(startTime, endTime)) { + return false; + } + // Check if startTime is between slot + else if (startTime.isBetween(time, slotEndTime)) { + return false; + } + // Check if timeslot has before buffer time space free + else if ( + slotStartTimeWithBeforeBuffer.isBetween( + startTime.subtract(beforeBufferTime, "minutes"), + endTime.add(afterBufferTime, "minutes") + ) + ) { + return false; + } + // Check if timeslot has after buffer time space free + else if ( + slotEndTimeWithAfterBuffer.isBetween( + startTime.subtract(beforeBufferTime, "minutes"), + endTime.add(afterBufferTime, "minutes") + ) + ) { + return false; + } + return true; + }); +}; + +export const slotsRouter = createRouter().query("getSchedule", { + input: getScheduleSchema, + async resolve({ input, ctx }) { + const eventType = await ctx.prisma.eventType.findUnique({ + where: { + id: input.eventTypeId, + }, + select: { + id: true, + minimumBookingNotice: true, + length: true, + seatsPerTimeSlot: true, + timeZone: true, + slotInterval: true, + beforeEventBuffer: true, + afterEventBuffer: true, + schedulingType: true, + schedule: { + select: { + availability: true, + timeZone: true, + }, + }, + availability: { + select: { + startTime: true, + endTime: true, + days: true, + }, + }, + users: { + select: { + username: true, + ...availabilityUserSelect, + }, + }, + }, + }); + + if (!eventType) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + const { startTime, endTime } = input; + if (!startTime.isValid() || !endTime.isValid()) { + throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" }); + } + + let currentSeats: CurrentSeats | undefined = undefined; + + const userSchedules = await Promise.all( + eventType.users.map(async (currentUser) => { + const { + busy, + workingHours, + currentSeats: _currentSeats, + } = await getUserAvailability( + { + userId: currentUser.id, + dateFrom: startTime.format(), + dateTo: endTime.format(), + eventTypeId: input.eventTypeId, + }, + { user: currentUser, eventType, currentSeats } + ); + if (!currentSeats && _currentSeats) currentSeats = _currentSeats; + + return { + workingHours, + busy, + }; + }) + ); + const workingHours = userSchedules.flatMap((s) => s.workingHours); + console.log("workingHours", workingHours); + console.log("currentSeats", currentSeats); + + const slots: Record = {}; + + const availabilityCheckProps = { + eventLength: eventType.length, + beforeBufferTime: eventType.beforeEventBuffer, + afterBufferTime: eventType.afterEventBuffer, + currentSeats, + }; + + let time = dayjs(startTime); + do { + // get slots retrieves the available times for a given day + const times = getSlots({ + inviteeDate: time, + eventLength: eventType.length, + workingHours, + minimumBookingNotice: eventType.minimumBookingNotice, + frequency: eventType.slotInterval || eventType.length, + }); + + // if ROUND_ROBIN - slots stay available on some() - if normal / COLLECTIVE - slots only stay available on every() + const filteredTimes = + !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE + ? times.filter((time) => + userSchedules.every((schedule) => + checkForAvailability({ time, ...schedule, ...availabilityCheckProps }) + ) + ) + : times.filter((time) => + userSchedules.some((schedule) => + checkForAvailability({ time, ...schedule, ...availabilityCheckProps }) + ) + ); + + slots[yyyymmdd(time.toDate())] = filteredTimes.map((time) => ({ + time: time.toISOString(), + users: eventType.users.map((user) => user.username || ""), + // Conditionally add the attendees and booking id to slots object if there is already a booking during that time + ...(currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString()) && { + attendees: + currentSeats[ + currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString()) + ]._count.attendees, + bookingUid: + currentSeats[ + currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString()) + ].uid, + }), + })); + time = time.add(1, "day"); + } while (time.isBefore(endTime)); + + return { + slots, + }; + }, +}); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 8513b6ada4..fa185fac44 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -1,5 +1,5 @@ import { Prisma } from "@prisma/client"; -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; import { z } from "zod"; import { getWorkingHours } from "@calcom/lib/availability"; @@ -24,6 +24,7 @@ const getEventType = (id: number) => prisma.eventType.findUnique({ where: { id }, select: { + id: true, seatsPerTimeSlot: true, timeZone: true, schedule: { @@ -52,6 +53,28 @@ const getUser = (where: Prisma.UserWhereUniqueInput) => type User = Awaited>; +export const getCurrentSeats = (eventTypeId: number, dateFrom: Dayjs, dateTo: Dayjs) => + prisma.booking.findMany({ + where: { + eventTypeId, + startTime: { + gte: dateFrom.format(), + lte: dateTo.format(), + }, + }, + select: { + uid: true, + startTime: true, + _count: { + select: { + attendees: true, + }, + }, + }, + }); + +export type CurrentSeats = Awaited>; + export async function getUserAvailability( query: { username?: string; @@ -64,6 +87,7 @@ export async function getUserAvailability( initialData?: { user?: User; eventType?: EventType; + currentSeats?: CurrentSeats; } ) { const { username, userId, dateFrom, dateTo, eventTypeId, timezone } = availabilitySchema.parse(query); @@ -82,6 +106,12 @@ export async function getUserAvailability( let eventType: EventType | null = initialData?.eventType || null; if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId); + /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab + current bookings with a seats event type and display them on the calendar, even if they are full */ + let currentSeats: CurrentSeats | null = initialData?.currentSeats || null; + if (!currentSeats && eventType?.seatsPerTimeSlot) + currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo); + const { selectedCalendars, ...currentUser } = user; const busyTimes = await getBusyTimes({ @@ -98,8 +128,6 @@ export async function getUserAvailability( end: dayjs(a.end).add(currentUser.bufferTime, "minute").toISOString(), })); - const timeZone = timezone || eventType?.timeZone || currentUser.timeZone; - const schedule = eventType?.schedule ? { ...eventType?.schedule } : { @@ -108,36 +136,14 @@ export async function getUserAvailability( )[0], }; + const timeZone = timezone || schedule?.timeZone || eventType?.timeZone || currentUser.timeZone; + const workingHours = getWorkingHours( { timeZone }, schedule.availability || (eventType?.availability.length ? eventType.availability : currentUser.availability) ); - /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab - current bookings with a seats event type and display them on the calendar, even if they are full */ - let currentSeats; - if (eventType?.seatsPerTimeSlot) { - currentSeats = await prisma.booking.findMany({ - where: { - eventTypeId: eventTypeId, - startTime: { - gte: dateFrom.format(), - lte: dateTo.format(), - }, - }, - select: { - uid: true, - startTime: true, - _count: { - select: { - attendees: true, - }, - }, - }, - }); - } - return { busy: bufferedBusyTimes, timeZone, diff --git a/packages/lib/date-fns/index.ts b/packages/lib/date-fns/index.ts new file mode 100644 index 0000000000..fe763d4e1c --- /dev/null +++ b/packages/lib/date-fns/index.ts @@ -0,0 +1,6 @@ +import dayjs from "dayjs"; + +// converts a date to 2022-04-25 for example. +export const yyyymmdd = (date: Date) => dayjs(date).format("YYYY-MM-DD"); + +export const daysInMonth = (date: Date) => dayjs(date).daysInMonth(); diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 56d8e03446..8abf455230 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -154,29 +154,23 @@ export const getUsernameSlugLink = ({ users, slug }: UsernameSlugLinkProps): str return slugLink; }; +const arrayCast = (value: unknown | unknown[]) => { + return Array.isArray(value) ? value : value ? [value] : []; +}; + export const getUsernameList = (users: string | string[] | undefined): string[] => { - if (!users) { - return []; - } - if (!(users instanceof Array)) { - users = [users]; - } - const allUsers: string[] = []; // Multiple users can come in case of a team round-robin booking and in that case dynamic link won't be a user. // So, even though this code handles even if individual user is dynamic link, that isn't a possibility right now. - users.forEach((user) => { - allUsers.push( - ...user - ?.toLowerCase() - .replace(/ /g, "+") - .replace(/%20/g, "+") - .split("+") - .filter((el) => { - return el.length != 0; - }) - ); - }); - return allUsers; + users = arrayCast(users); + + const allUsers = users.map((user) => + user + .toLowerCase() + .replace(/( |%20)/g, "+") + .split("+") + ); + + return Array.prototype.concat(...allUsers); }; export default defaultEvents; diff --git a/packages/ui/booker/DatePicker.tsx b/packages/ui/booker/DatePicker.tsx new file mode 100644 index 0000000000..c7606e92c8 --- /dev/null +++ b/packages/ui/booker/DatePicker.tsx @@ -0,0 +1,200 @@ +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; +import dayjs from "dayjs"; +import isToday from "dayjs/plugin/isToday"; +import { useMemo, useState } from "react"; + +import classNames from "@calcom/lib/classNames"; +import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns"; +import { weekdayNames } from "@calcom/lib/weekday"; + +dayjs.extend(isToday); + +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: Date) => void; + /** Fires when the month is changed. */ + onMonthChange?: (date: Date) => void; + /** which date is currently selected (not tracked from here) */ + selected?: Date; + /** 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; +}; + +const Day = ({ + date, + active, + ...props +}: JSX.IntrinsicElements["button"] & { active: boolean; date: Date }) => { + return ( + + ); +}; + +const Days = ({ + minDate, + excludedDates = [], + includedDates = [], + browsingDate, + weekStart, + selected, + ...props +}: Omit & { + browsingDate: Date; + weekStart: number; +}) => { + // Create placeholder elements for empty days in first week + const weekdayOfFirst = new Date(new Date(browsingDate).setDate(1)).getDay(); + // memoize to prevent a flicker on redraw on the current day + const minDateValueOf = useMemo(() => { + return minDate?.valueOf() || new Date().valueOf(); + }, [minDate]); + + const days: (Date | null)[] = Array((weekdayOfFirst - weekStart + 7) % 7).fill(null); + for (let day = 1, dayCount = daysInMonth(browsingDate); day <= dayCount; day++) { + const date = new Date(new Date(browsingDate).setDate(day)); + days.push(date); + } + + return ( + <> + {days.map((day, idx) => ( +
+ {day === null ? ( +
+ ) : ( + props.onChange(day)} + disabled={ + !includedDates.includes(yyyymmdd(day)) || + excludedDates.includes(yyyymmdd(day)) || + day.valueOf() < minDateValueOf + } + active={selected ? yyyymmdd(selected) === yyyymmdd(day) : false} + /> + )} +
+ ))} + + ); +}; + +const Spinner = () => ( + + + + +); + +const DatePicker = ({ + weekStart = 0, + className, + locale, + selected, + onMonthChange, + isLoading = false, + ...passThroughProps +}: DatePickerProps) => { + const [month, setMonth] = useState(selected ? selected.getMonth() : new Date().getMonth()); + + const changeMonth = (newMonth: number) => { + setMonth(newMonth); + if (onMonthChange) { + const d = new Date(); + d.setMonth(newMonth, 1); + onMonthChange(d); + } + }; + + return ( +
+
+ + + {new Date(new Date().setMonth(month)).toLocaleString(locale, { month: "long" })} + {" "} + {new Date(new Date().setMonth(month)).getFullYear()} + +
+ {isLoading && } + + +
+
+
+ {weekdayNames(locale, weekStart, "short").map((weekDay) => ( +
+ {weekDay} +
+ ))} +
+
+ +
+
+ ); +}; + +export default DatePicker; diff --git a/yarn.lock b/yarn.lock index df17e00a35..c651b7447f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17625,4 +17625,4 @@ zwitch@^1.0.0: zwitch@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1" - integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== + integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== \ No newline at end of file