import { AnimatePresence, LazyMotion, m } from "framer-motion"; import dynamic from "next/dynamic"; import { useEffect, useMemo, useRef } from "react"; import StickyBox from "react-sticky-box"; import { Toaster } from "sonner"; import { shallow } from "zustand/shallow"; import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; import { useIsPlatformBookerEmbed } from "@calcom/atoms/monorepo"; import dayjs from "@calcom/dayjs"; import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep"; import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules"; import classNames from "@calcom/lib/classNames"; import { CLOUDFLARE_SITE_ID, CLOUDFLARE_USE_TURNSTILE_IN_BOOKER } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { VerifyCodeDialog } from "../components/VerifyCodeDialog"; import { AvailableTimeSlots } from "./components/AvailableTimeSlots"; import { BookEventForm } from "./components/BookEventForm"; import { BookFormAsModal } from "./components/BookEventForm/BookFormAsModal"; import { DryRunMessage } from "./components/DryRunMessage"; import { EventMeta } from "./components/EventMeta"; import { HavingTroubleFindingTime } from "./components/HavingTroubleFindingTime"; import { Header } from "./components/Header"; import { InstantBooking } from "./components/InstantBooking"; import { LargeCalendar } from "./components/LargeCalendar"; import { OverlayCalendar } from "./components/OverlayCalendar/OverlayCalendar"; import { RedirectToInstantMeetingModal } from "./components/RedirectToInstantMeetingModal"; import { BookerSection } from "./components/Section"; import { NotFound } from "./components/Unavailable"; import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config"; import { useBookerStore } from "./store"; import type { BookerProps, WrappedBookerProps } from "./types"; import { isBookingDryRun } from "./utils/isBookingDryRun"; const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false }); const loadFramerFeatures = () => import("./framer-features").then((res) => res.default); const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy").then((mod) => mod.default)); const UnpublishedEntity = dynamic(() => import("@calcom/ui/components/unpublished-entity/UnpublishedEntity").then((mod) => mod.UnpublishedEntity) ); const DatePicker = dynamic(() => import("./components/DatePicker").then((mod) => mod.DatePicker), { ssr: false, }); const BookerComponent = ({ username, eventSlug, hideBranding = false, entity, isInstantMeeting = false, onGoBackInstantMeeting, onConnectNowInstantMeeting, onOverlayClickNoCalendar, onClickOverlayContinue, onOverlaySwitchStateChange, sessionUsername, rescheduleUid, hasSession, extraOptions, bookings, verifyEmail, slots, calendars, bookerForm, event, bookerLayout, schedule, verifyCode, isPlatform, orgBannerUrl, customClassNames, areInstantMeetingParametersSet = false, userLocale, hasValidLicense, isBookingDryRun: isBookingDryRunProp, renderCaptcha, hashedLink, }: BookerProps & WrappedBookerProps) => { const searchParams = useCompatSearchParams(); const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); const selectedDate = useBookerStore((state) => state.selectedDate); const { shouldShowFormInDialog, hasDarkBackground, extraDays, columnViewExtraDays, isMobile, layout, hideEventTypeDetails, isEmbed, bookerLayouts, } = bookerLayout; const [seatedEventData, setSeatedEventData] = useBookerStore( (state) => [state.seatedEventData, state.setSeatedEventData], shallow ); const { selectedTimeslot, setSelectedTimeslot } = slots; const [dayCount, setDayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow); const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter( (slot) => dayjs(selectedDate).diff(slot, "day") <= 0 ); const totalWeekDays = 7; const addonDays = nonEmptyScheduleDays.length < extraDays ? (extraDays - nonEmptyScheduleDays.length + 1) * totalWeekDays : nonEmptyScheduleDays.length === extraDays ? totalWeekDays : 0; // Taking one more available slot(extraDays + 1) to calculate the no of days in between, that next and prev button need to shift. const availableSlots = nonEmptyScheduleDays.slice(0, extraDays + 1); if (nonEmptyScheduleDays.length !== 0) columnViewExtraDays.current = Math.abs(dayjs(selectedDate).diff(availableSlots[availableSlots.length - 2], "day")) + addonDays; const nextSlots = Math.abs(dayjs(selectedDate).diff(availableSlots[availableSlots.length - 1], "day")) + addonDays; const animationScope = useBookerResizeAnimation(layout, bookerState); const timeslotsRef = useRef(null); const StickyOnDesktop = isMobile ? "div" : StickyBox; const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm; const { handleBookEvent, errors, loadingStates, expiryTime, instantVideoMeetingUrl } = bookings; const watchedCfToken = bookingForm.watch("cfToken"); const { isEmailVerificationModalVisible, setEmailVerificationModalVisible, handleVerifyEmail, renderConfirmNotVerifyEmailButtonCond, isVerificationCodeSending, } = verifyEmail; const { overlayBusyDates, isOverlayCalendarEnabled, connectedCalendars, loadingConnectedCalendar, onToggleCalendar, } = calendars; const scrolledToTimeslotsOnce = useRef(false); const scrollToTimeSlots = () => { if (isMobile && !isEmbed && !scrolledToTimeslotsOnce.current) { // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Conditional within !isEmbed condition timeslotsRef.current?.scrollIntoView({ behavior: "smooth" }); scrolledToTimeslotsOnce.current = true; } }; const skipConfirmStep = useSkipConfirmStep( bookingForm, bookerState, isInstantMeeting, layout == BookerLayouts.WEEK_VIEW, event?.data?.bookingFields ); // Cloudflare Turnstile Captcha const shouldRenderCaptcha = !!( !process.env.NEXT_PUBLIC_IS_E2E && renderCaptcha && CLOUDFLARE_SITE_ID && CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1" && (bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep)) ); useEffect(() => { if (event.isPending) return setBookerState("loading"); if (!selectedDate) return setBookerState("selecting_date"); if (!selectedTimeslot) return setBookerState("selecting_time"); const isSkipConfirmStepSupported = !isInstantMeeting && layout !== BookerLayouts.WEEK_VIEW; if (selectedTimeslot && skipConfirmStep && isSkipConfirmStepSupported) return setBookerState("selecting_time"); return setBookerState("booking"); }, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep, layout, isInstantMeeting]); const slot = getQueryParam("slot"); useEffect(() => { setSelectedTimeslot(slot || null); }, [slot, setSelectedTimeslot]); const onSubmit = (timeSlot?: string) => { renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail(); }; const EventBooker = useMemo(() => { return bookerState === "booking" ? ( { setSelectedTimeslot(null); if (seatedEventData.bookingUid) { setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined }); } }} onSubmit={() => (renderConfirmNotVerifyEmailButtonCond ? handleBookEvent() : handleVerifyEmail())} errorRef={bookerFormErrorRef} errors={{ ...formErrors, ...errors }} loadingStates={loadingStates} renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond} bookingForm={bookingForm} eventQuery={event} extraOptions={extraOptions} rescheduleUid={rescheduleUid} isVerificationCodeSending={isVerificationCodeSending} isPlatform={isPlatform}> <> {!isPlatform && ( { onGoBackInstantMeeting(); }} orgName={event.data?.entity?.name} /> )} ) : ( <> ); }, [ bookerFormErrorRef, instantVideoMeetingUrl, bookerState, bookingForm, errors, event, expiryTime, extraOptions, formErrors, handleBookEvent, handleVerifyEmail, key, loadingStates, onGoBackInstantMeeting, renderConfirmNotVerifyEmailButtonCond, rescheduleUid, seatedEventData, setSeatedEventData, setSelectedTimeslot, isPlatform, shouldRenderCaptcha, isVerificationCodeSending, ]); /** * Unpublished organization handling - Below * - Reschedule links in email are of the organization event for an unpublished org, so we need to allow rescheduling unpublished event * - Ideally, we should allow rescheduling only for the event that has an old link(non-org link) but that's a bit complex and we are fine showing all reschedule links on unpublished organization */ const considerUnpublished = entity.considerUnpublished && !rescheduleUid; if (considerUnpublished) { return ; } if (event.isSuccess && !event.data) { return ; } if (bookerState === "loading") { return null; } return ( <> {event.data && !isPlatform ? : <>} {(isBookingDryRunProp || isBookingDryRun(searchParams)) && }
{!isInstantMeeting && ( {isPlatform && layout === BookerLayouts.MONTH_VIEW ? ( <> ) : (
isEmbed ? ( <> ) : ( <> { onOverlayClickNoCalendar(); }} /> ) } /> )} )} {!hideEventTypeDetails && orgBannerUrl && ( org banner )} {layout !== BookerLayouts.MONTH_VIEW && !(layout === "mobile" && bookerState === "booking") && (
)}
{EventBooker}
{ setDayCount(null); }} /> {bookerState !== "booking" && event.data?.showInstantEventConnectNowModal && areInstantMeetingParametersSet && (
{ onConnectNowInstantMeeting(); }} />
)} {shouldRenderCaptcha && (
{ bookingForm.setValue("cfToken", token); }} />
)} {!hideBranding && (!isPlatform || isPlatformBookerEmbed) && !shouldRenderCaptcha && ( )}
<> {verifyCode && formEmail ? ( ) : ( <> )} setSelectedTimeslot(null)} visible={bookerState === "booking" && shouldShowFormInDialog}> {EventBooker} ); }; export const Booker = (props: BookerProps & WrappedBookerProps) => { return ( ); };