import process from "node:process"; import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed"; import dayjs from "@calcom/dayjs"; import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe"; import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe"; import TurnstileCaptcha from "@calcom/web/modules/auth/components/Turnstile"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { useIsQuickAvailabilityCheckFeatureEnabled } from "../hooks/useIsQuickAvailabilityCheckFeatureEnabled"; import { useSkipConfirmStep } from "@calcom/web/modules/bookings/hooks/useSkipConfirmStep"; import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation, } from "@calcom/features/bookings/Booker/config"; import framerFeatures from "@calcom/features/bookings/Booker/framer-features"; import type { BookerProps } from "@calcom/features/bookings/Booker/types"; import type { WrappedBookerProps } from "../types"; import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun"; import { isTimeSlotAvailable } from "@calcom/features/bookings/Booker/utils/isTimeslotAvailable"; import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { Dialog } from "@calcom/features/components/controlled-dialog"; import { useNonEmptyScheduleDays } from "@calcom/web/modules/schedules/hooks/useNonEmptyScheduleDays"; import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils"; import { CLOUDFLARE_SITE_ID, CLOUDFLARE_USE_TURNSTILE_IN_BOOKER, PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM, } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import classNames from "@calcom/ui/classNames"; import { DialogContent } from "@calcom/ui/components/dialog"; import { UnpublishedEntity } from "@calcom/ui/components/unpublished-entity"; import PoweredBy from "@calcom/web/modules/ee/common/components/PoweredBy"; import { AnimatePresence, LazyMotion, m } from "framer-motion"; import { useEffect, useMemo, useRef } from "react"; import StickyBox from "react-sticky-box"; import { Toaster } from "sonner"; import { shallow } from "zustand/shallow"; import { AvailableTimeSlots } from "./AvailableTimeSlots"; import { BookEventForm } from "./BookEventForm"; import { BookFormAsModal } from "./BookEventForm/BookFormAsModal"; import { DatePicker } from "./DatePicker"; import { DryRunMessage } from "./DryRunMessage"; import { EventMeta } from "./EventMeta"; import { HavingTroubleFindingTime } from "./HavingTroubleFindingTime"; import { Header } from "@calcom/features/bookings/components/Header"; import { InstantBooking } from "./InstantBooking"; import { LargeCalendar } from "./LargeCalendar"; import { OverlayCalendar } from "./OverlayCalendar/OverlayCalendar"; import { RedirectToInstantMeetingModal } from "./RedirectToInstantMeetingModal"; import { BookerSection } from "@calcom/features/bookings/components/Section"; import { SlotSelectionModalHeader } from "./SlotSelectionModalHeader"; import { NotFound } from "./Unavailable"; import { VerifyCodeDialog } from "./VerifyCodeDialog"; 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, confirmButtonDisabled, timeZones, eventMetaChildren, roundRobinHideOrgAndTeam, hideOrgTeamAvatar, showNoAvailabilityDialog, }: BookerProps & WrappedBookerProps): JSX.Element | null => { const searchParams = useCompatSearchParams(); const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); const [bookerState, setBookerState] = useBookerStoreContext( (state) => [state.state, state.setState], shallow ); const selectedDate = useBookerStoreContext((state) => state.selectedDate); const [isSlotSelectionModalVisible, setIsSlotSelectionModalVisible] = useBookerStoreContext( (state) => [state.isSlotSelectionModalVisible, state.setIsSlotSelectionModalVisible], shallow ); const { shouldShowFormInDialog, hasDarkBackground, extraDays, columnViewExtraDays, isMobile, layout, hideEventTypeDetails, isEmbed, bookerLayouts, slotsViewOnSmallScreen, } = bookerLayout; const [seatedEventData, setSeatedEventData] = useBookerStoreContext( (state) => [state.seatedEventData, state.setSeatedEventData], shallow ); const { selectedTimeslot, setSelectedTimeslot, allSelectedTimeslots } = slots; const [dayCount, setDayCount] = useBookerStoreContext( (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 : 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) { const slotIndex = availableSlots.length === extraDays + 1 ? availableSlots.length - 2 : availableSlots.length - 1; columnViewExtraDays.current = Math.abs(dayjs(selectedDate).diff(availableSlots[slotIndex], "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 isQuickAvailabilityCheckFeatureEnabled = useIsQuickAvailabilityCheckFeatureEnabled(); const StickyOnDesktop = isMobile ? "div" : StickyBox; const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm; const { handleBookEvent, errors, loadingStates, expiryTime, instantVideoMeetingUrl, instantConnectCooldownMs, } = bookings; const watchedCfToken = bookingForm.watch("cfToken"); const { isEmailVerificationModalVisible, setEmailVerificationModalVisible, handleVerifyEmail, renderConfirmNotVerifyEmailButtonCond, isVerificationCodeSending, } = verifyEmail; const { overlayBusyDates, isOverlayCalendarEnabled, connectedCalendars, onToggleCalendar } = calendars; const scrolledToTimeslotsOnce = useRef(false); const embedUiConfig = useEmbedUiConfig(); const scrollToTimeSlots = () => { // Don't scroll if slots view on small screen is enabled if (slotsViewOnSmallScreen) { return; } if ( isMobile && !scrolledToTimeslotsOnce.current && timeslotsRef.current && !embedUiConfig.disableAutoScroll ) { // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- We are allowing it here because scrollToTimeSlots is called on explicit user action where it makes sense to scroll, remember that the goal is to not do auto-scroll on embed load because that ends up scrolling the embedding webpage too scrollIntoViewSmooth(timeslotsRef.current, isEmbed); scrolledToTimeslotsOnce.current = true; } }; const skipConfirmStep = useSkipConfirmStep( bookingForm, bookerState, isInstantMeeting, layout == BookerLayouts.WEEK_VIEW, event?.data?.bookingFields, event?.data?.locations ); // Cloudflare Turnstile Captcha // Note: process.env may be undefined in embed contexts, so we safely check it const isE2E = typeof process !== "undefined" && process.env?.NEXT_PUBLIC_IS_E2E; const shouldRenderCaptcha = !!( !isE2E && renderCaptcha && CLOUDFLARE_SITE_ID && CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1" && (bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep)) ); const onAvailableTimeSlotSelect = (time: string) => { setSelectedTimeslot(time); if (!skipConfirmStep) { setIsSlotSelectionModalVisible(false); } }; updateEmbedBookerState({ bookerState, slotsQuery: schedule }); 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.isPending, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep, layout, isInstantMeeting, ]); const unavailableTimeSlots = isQuickAvailabilityCheckFeatureEnabled ? allSelectedTimeslots.filter((slot) => { return !isTimeSlotAvailable({ scheduleData: schedule?.data ?? null, slotToCheckInIso: slot, quickAvailabilityChecks: slots.quickAvailabilityChecks, }); }) : []; const slot = getQueryParam("slot"); useEffect(() => { setSelectedTimeslot(slot || null); }, [slot, setSelectedTimeslot]); const onSubmit = (timeSlot?: string) => renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail(); const EventBooker = useMemo(() => { if (bookerState !== "booking") { return null; } return ( { setSelectedTimeslot(null); // Temporarily allow disabling it, till we are sure that it doesn't cause any significant load on the system if (PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM) { // Ensures that user has latest available slots when they want to re-choose from the slots schedule?.invalidate(); } if (seatedEventData.bookingUid) { setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined, }); } }} onSubmit={() => (renderConfirmNotVerifyEmailButtonCond ? handleBookEvent() : handleVerifyEmail())} errorRef={bookerFormErrorRef} errors={{ ...formErrors, ...errors }} isTimeslotUnavailable={!isInstantMeeting && unavailableTimeSlots.includes(selectedTimeslot || "")} loadingStates={loadingStates} renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond} bookingForm={bookingForm} eventQuery={event} extraOptions={extraOptions} isVerificationCodeSending={isVerificationCodeSending} confirmButtonDisabled={confirmButtonDisabled} classNames={{ confirmButton: customClassNames?.confirmStep?.confirmButton, backButton: customClassNames?.confirmStep?.backButton, }} isPlatform={isPlatform}> {!isPlatform && ( { onGoBackInstantMeeting(); }} orgName={event.data?.entity?.name} /> )} ); }, [ bookerFormErrorRef, instantVideoMeetingUrl, bookerState, bookingForm, errors, event, expiryTime, extraOptions, formErrors, handleBookEvent, handleVerifyEmail, key, loadingStates, onGoBackInstantMeeting, renderConfirmNotVerifyEmailButtonCond, seatedEventData, setSeatedEventData, setSelectedTimeslot, isPlatform, shouldRenderCaptcha, isVerificationCodeSending, unavailableTimeSlots, ]); /** * 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 ? ( <> ) : (
{ if (isEmbed) return null; return ( { onOverlayClickNoCalendar(); }} /> ); }} /> )} )} {!hideEventTypeDetails && orgBannerUrl && ( org banner )} {!hideEventTypeDetails && ( {eventMetaChildren} )} {layout !== BookerLayouts.MONTH_VIEW && !(layout === "mobile" && bookerState === "booking") && (
{ if (slotsViewOnSmallScreen) { setIsSlotSelectionModalVisible(true); } }} />
)}
{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} setIsSlotSelectionModalVisible(false)} event={event.data} isPlatform={isPlatform} timeZones={timeZones} selectedDate={selectedDate} /> ); }; export const Booker = (props: BookerProps & WrappedBookerProps) => { return ( ); };