diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 816aa71fb5..6b9abf6c0b 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -161,7 +161,12 @@ const BookerComponent = ({ } }; - const skipConfirmStep = useSkipConfirmStep(bookingForm, event?.data?.bookingFields); + const skipConfirmStep = useSkipConfirmStep( + bookingForm, + bookerState, + isInstantMeeting, + event?.data?.bookingFields + ); // Cloudflare Turnstile Captcha const shouldRenderCaptcha = !!( @@ -175,7 +180,8 @@ const BookerComponent = ({ useEffect(() => { if (event.isPending) return setBookerState("loading"); if (!selectedDate) return setBookerState("selecting_date"); - if (!selectedTimeslot || skipConfirmStep) return setBookerState("selecting_time"); + if (!selectedTimeslot) return setBookerState("selecting_time"); + if (selectedTimeslot && skipConfirmStep && !isInstantMeeting) return setBookerState("selecting_time"); return setBookerState("booking"); }, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep]); @@ -212,22 +218,6 @@ const BookerComponent = ({ isVerificationCodeSending={isVerificationCodeSending} isPlatform={isPlatform}> <> - {verifyCode && formEmail ? ( - - ) : ( - <> - )} {!isPlatform && ( + <> + {verifyCode && formEmail ? ( + + ) : ( + <> + )} + + setSelectedTimeslot(null)} visible={bookerState === "booking" && shouldShowFormInDialog}> diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index fae80c8dd7..4ea3044f76 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -1,10 +1,11 @@ -import { useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import dayjs from "@calcom/dayjs"; import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/features/bookings"; import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules"; +import type { Slot } from "@calcom/features/schedules"; import { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate"; import { classNames } from "@calcom/lib"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; @@ -12,6 +13,7 @@ import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { AvailableTimesHeader } from "../../components/AvailableTimesHeader"; import { useBookerStore } from "../store"; import type { useScheduleForEventReturnType } from "../utils/event"; +import { getQueryParam } from "../utils/query-param"; type AvailableTimeSlotsProps = { extraDays?: number; @@ -55,10 +57,12 @@ export const AvailableTimeSlots = ({ isLoading, customClassNames, skipConfirmStep, + seatsPerTimeSlot, onSubmit, ...props }: AvailableTimeSlotsProps) => { const selectedDate = useBookerStore((state) => state.selectedDate); + const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); const setSeatedEventData = useBookerStore((state) => state.setSeatedEventData); const date = selectedDate || dayjs().format("YYYY-MM-DD"); @@ -66,27 +70,6 @@ export const AvailableTimeSlots = ({ const isColumnView = layout === BookerLayouts.COLUMN_VIEW; const containerRef = useRef(null); - const onTimeSelect = ( - time: string, - attendees: number, - seatsPerTimeSlot?: number | null, - bookingUid?: string - ) => { - setSelectedTimeslot(time); - if (seatsPerTimeSlot) { - setSeatedEventData({ - seatsPerTimeSlot, - attendees, - bookingUid, - showAvailableSeatsCount, - }); - } - if (skipConfirmStep) { - onSubmit(time); - } - return; - }; - const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.slots); const nonEmptyScheduleDaysFromSelectedDate = nonEmptyScheduleDays.filter( (slot) => dayjs(selectedDate).diff(slot, "day") <= 0 @@ -94,13 +77,53 @@ export const AvailableTimeSlots = ({ // Creates an array of dates to fetch slots for. // If `extraDays` is passed in, we will extend the array with the next `extraDays` days. - const dates = !extraDays - ? [date] - : nonEmptyScheduleDaysFromSelectedDate.length > 0 - ? nonEmptyScheduleDaysFromSelectedDate.slice(0, extraDays) - : []; + const dates = useMemo(() => { + if (!extraDays) return [date]; + if (nonEmptyScheduleDaysFromSelectedDate.length > 0) { + return nonEmptyScheduleDaysFromSelectedDate.slice(0, extraDays); + } + return []; + }, [date, extraDays, nonEmptyScheduleDaysFromSelectedDate]); - const slotsPerDay = useSlotsForAvailableDates(dates, schedule?.slots); + const { slotsPerDay, toggleConfirmButton } = useSlotsForAvailableDates(dates, schedule?.slots); + + const overlayCalendarToggled = + getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault"); + + const onTimeSelect = useCallback( + (time: string, attendees: number, seatsPerTimeSlot?: number | null, bookingUid?: string) => { + setSelectedTimeslot(time); + if (seatsPerTimeSlot) { + setSeatedEventData({ + seatsPerTimeSlot, + attendees, + bookingUid, + showAvailableSeatsCount, + }); + } + if (skipConfirmStep) { + onSubmit(time); + } + return; + }, + [onSubmit, setSeatedEventData, setSelectedTimeslot, skipConfirmStep, showAvailableSeatsCount] + ); + + const handleSlotClick = useCallback( + (selectedSlot: Slot, isOverlapping: boolean) => { + if ((overlayCalendarToggled && isOverlapping) || skipConfirmStep) { + toggleConfirmButton(selectedSlot); + } else { + onTimeSelect( + selectedSlot.time, + selectedSlot?.attendees || 0, + seatsPerTimeSlot, + selectedSlot.bookingUid + ); + } + }, + [overlayCalendarToggled, onTimeSelect, seatsPerTimeSlot, skipConfirmStep, toggleConfirmButton] + ); return ( <> @@ -150,6 +173,8 @@ export const AvailableTimeSlots = ({ slots={slots.slots} showAvailableSeatsCount={showAvailableSeatsCount} skipConfirmStep={skipConfirmStep} + seatsPerTimeSlot={seatsPerTimeSlot} + handleSlotClick={handleSlotClick} {...props} /> diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index 60ab42fe28..ead2e24b10 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -124,7 +124,6 @@ export const BookEventForm = ({ /> )} - {/* Cloudflare Turnstile Captcha */} {!isPlatform && (
{ const bookingFormValues = bookingForm.getValues(); @@ -34,8 +38,8 @@ const useSkipConfirmStep = ( } }; - checkSkipStep(); - }, [bookingFormValues, bookingFields, rescheduleUid]); + bookerState === "selecting_time" && !isInstantMeeting && checkSkipStep(); + }, [bookingFormValues, bookingFields, rescheduleUid, bookerState]); return canSkip; }; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 238f7dd739..1123ba7cdc 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -1,7 +1,7 @@ // We do not need to worry about importing framer-motion here as it is lazy imported in Booker. import * as HoverCard from "@radix-ui/react-hover-card"; import { AnimatePresence, m } from "framer-motion"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo } from "react"; import { useIsPlatform } from "@calcom/atoms/monorepo"; import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability"; @@ -9,7 +9,7 @@ import dayjs from "@calcom/dayjs"; import { OutOfOfficeInSlots } from "@calcom/features/bookings/Booker/components/OutOfOfficeInSlots"; import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import type { BookerEvent } from "@calcom/features/bookings/types"; -import type { Slots } from "@calcom/features/schedules"; +import type { Slot } from "@calcom/features/schedules"; import { classNames } from "@calcom/lib"; import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -37,7 +37,7 @@ export type AvailableTimesProps = { } & Omit; type SlotItemProps = { - slot: Slots[string][number]; + slot: Slot; seatsPerTimeSlot?: number | null; selectedSlots?: string[]; onTimeSelect: TOnTimeSelect; @@ -52,6 +52,7 @@ type SlotItemProps = { skipConfirmStep?: boolean; shouldRenderCaptcha?: boolean; watchedCfToken?: string; + handleSlotClick?: (slot: Slot, isOverlapping: boolean) => void; }; const SlotItem = ({ @@ -68,6 +69,7 @@ const SlotItem = ({ skipConfirmStep, shouldRenderCaptcha, watchedCfToken, + handleSlotClick, }: SlotItemProps) => { const { t } = useLocale(); @@ -106,26 +108,6 @@ const SlotItem = ({ offset, }); - const [showConfirm, setShowConfirm] = useState(false); - - const onButtonClick = useCallback(() => { - if (!showConfirm && ((overlayCalendarToggled && isOverlapping) || skipConfirmStep)) { - setShowConfirm(true); - return; - } - onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); - }, [ - overlayCalendarToggled, - isOverlapping, - showConfirm, - onTimeSelect, - slot.time, - slot?.attendees, - slot.bookingUid, - seatsPerTimeSlot, - skipConfirmStep, - ]); - return (
@@ -143,7 +125,7 @@ const SlotItem = ({ data-testid="time" data-disabled={bookingFull} data-time={slot.time} - onClick={onButtonClick} + onClick={() => handleSlotClick && handleSlotClick(slot, isOverlapping)} className={classNames( `hover:border-brand-default min-h-9 mb-2 flex h-auto w-full flex-grow flex-col justify-center py-2`, selectedSlots?.includes(slot.time) && "border-brand-default", @@ -176,47 +158,40 @@ const SlotItem = ({

)} - {showConfirm && ( + {!!slot.showConfirmButton && ( - - {skipConfirmStep ? ( - - ) : ( - - )} + + {isOverlapping && ( diff --git a/packages/features/schedules/index.ts b/packages/features/schedules/index.ts index fe06d0e457..68433e2936 100644 --- a/packages/features/schedules/index.ts +++ b/packages/features/schedules/index.ts @@ -1,3 +1,3 @@ export * from "./components"; -export type { Slots } from "./lib/use-schedule"; +export type { Slots, Slot } from "./lib/use-schedule"; export { useSchedule, useSlotsForDate, useNonEmptyScheduleDays } from "./lib/use-schedule"; diff --git a/packages/features/schedules/lib/use-schedule/index.ts b/packages/features/schedules/lib/use-schedule/index.ts index 9b824cc91b..a4c448a7fc 100644 --- a/packages/features/schedules/lib/use-schedule/index.ts +++ b/packages/features/schedules/lib/use-schedule/index.ts @@ -1,4 +1,4 @@ export { useSchedule } from "./useSchedule"; export { useSlotsForDate } from "./useSlotsForDate"; export { useNonEmptyScheduleDays } from "./useNonEmptyScheduleDays"; -export type { Slots, GetSchedule } from "./types"; +export type { Slots, GetSchedule, Slot } from "./types"; diff --git a/packages/features/schedules/lib/use-schedule/types.ts b/packages/features/schedules/lib/use-schedule/types.ts index 390427fccf..6c791376da 100644 --- a/packages/features/schedules/lib/use-schedule/types.ts +++ b/packages/features/schedules/lib/use-schedule/types.ts @@ -2,4 +2,6 @@ import type { RouterOutputs } from "@calcom/trpc/react"; export type Slots = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"]["slots"]; +export type Slot = Slots[string][number] & { showConfirmButton?: boolean }; + export type GetSchedule = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"]; diff --git a/packages/features/schedules/lib/use-schedule/useSlotsForDate.ts b/packages/features/schedules/lib/use-schedule/useSlotsForDate.ts index 8014fa3689..ff22adb3cd 100644 --- a/packages/features/schedules/lib/use-schedule/useSlotsForDate.ts +++ b/packages/features/schedules/lib/use-schedule/useSlotsForDate.ts @@ -1,6 +1,6 @@ -import { useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import type { Slots } from "./types"; +import type { Slots, Slot } from "./types"; /** * Gets slots for a specific date from the schedule cache. @@ -18,14 +18,35 @@ export const useSlotsForDate = (date: string | null, slots?: Slots) => { }; export const useSlotsForAvailableDates = (dates: (string | null)[], slots?: Slots) => { - const slotsForDates = useMemo(() => { - if (slots === undefined) return []; - return dates + const [slotsPerDay, setSlotsPerDay] = useState<{ date: string | null; slots: Slots[string] }[]>([]); + + const toggleConfirmButton = useCallback((selectedSlot: Slot) => { + setSlotsPerDay((prevSlotsPerDay) => + prevSlotsPerDay.map(({ date, slots }) => ({ + date, + slots: slots.map((slot) => ({ + ...slot, + showConfirmButton: slot.time === selectedSlot.time ? !selectedSlot?.showConfirmButton : false, + })), + })) + ); + }, []); + + useEffect(() => { + if (slots === undefined) { + setSlotsPerDay([]); + return; + } + + const updatedSlots = dates .filter((date) => date !== null) .map((date) => ({ slots: slots[`${date}`] || [], date, })); - }, [dates, slots]); - return slotsForDates; + + setSlotsPerDay(updatedSlots); + }, [JSON.stringify(dates), JSON.stringify(slots)]); + + return { slotsPerDay, setSlotsPerDay, toggleConfirmButton } as const; }; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 7217c6709f..583c644b5e 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -521,8 +521,8 @@ export const BookerPlatformWrapper = ( onOverlaySwitchStateChange={onOverlaySwitchStateChange} extraOptions={extraOptions ?? {}} bookings={{ - handleBookEvent: () => { - handleBookEvent(); + handleBookEvent: (timeSlot?: string) => { + handleBookEvent(timeSlot); return; }, expiryTime: undefined, diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index 830773fbf5..bfc4220fc5 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -1,3 +1,4 @@ +import { useIsPlatform } from "@calcom/atoms/monorepo"; import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime"; import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; @@ -7,9 +8,11 @@ import type { BookerEvent } from "@calcom/features/bookings/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RoutingFormSearchParams } from "@calcom/platform-types"; import type { BookingCreateBody } from "@calcom/prisma/zod-utils"; +import { showToast } from "@calcom/ui"; import type { UseCreateBookingInput } from "./useCreateBooking"; +type Callbacks = { onSuccess?: () => void; onError?: (err: any) => void }; type UseHandleBookingProps = { bookingForm: UseBookingFormReturnType["bookingForm"]; event?: { @@ -20,9 +23,9 @@ type UseHandleBookingProps = { }; metadata: Record; hashedLink?: string | null; - handleBooking: (input: UseCreateBookingInput) => void; - handleInstantBooking: (input: BookingCreateBody) => void; - handleRecBooking: (input: BookingCreateBody[]) => void; + handleBooking: (input: UseCreateBookingInput, callbacks?: Callbacks) => void; + handleInstantBooking: (input: BookingCreateBody, callbacks?: Callbacks) => void; + handleRecBooking: (input: BookingCreateBody[], callbacks?: Callbacks) => void; locationUrl?: string; routingFormSearchParams?: RoutingFormSearchParams; }; @@ -38,6 +41,7 @@ export const useHandleBookEvent = ({ locationUrl, routingFormSearchParams, }: UseHandleBookingProps) => { + const isPlatform = useIsPlatform(); const setFormValues = useBookerStore((state) => state.setFormValues); const storeTimeSlot = useBookerStore((state) => state.selectedTimeslot); const duration = useBookerStore((state) => state.selectedDuration); @@ -54,10 +58,15 @@ export const useHandleBookEvent = ({ const teamMemberEmail = useBookerStore((state) => state.teamMemberEmail); const crmOwnerRecordType = useBookerStore((state) => state.crmOwnerRecordType); const crmAppSlug = useBookerStore((state) => state.crmAppSlug); + const handleError = (err: any) => { + const errorMessage = err?.message ? t(err.message) : t("can_you_try_again"); + showToast(errorMessage, "error"); + }; const handleBookEvent = (inputTimeSlot?: string) => { const values = bookingForm.getValues(); const timeslot = inputTimeSlot ?? storeTimeSlot; + const callbacks = inputTimeSlot && !isPlatform ? { onError: handleError } : undefined; if (timeslot) { // Clears form values stored in store, so old values won't stick around. setFormValues({}); @@ -101,11 +110,11 @@ export const useHandleBookEvent = ({ }; if (isInstantMeeting) { - handleInstantBooking(mapBookingToMutationInput(bookingInput)); + handleInstantBooking(mapBookingToMutationInput(bookingInput), callbacks); } else if (event.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) { - handleRecBooking(mapRecurringBookingToMutationInput(bookingInput, recurringEventCount)); + handleRecBooking(mapRecurringBookingToMutationInput(bookingInput, recurringEventCount), callbacks); } else { - handleBooking({ ...mapBookingToMutationInput(bookingInput), locationUrl }); + handleBooking({ ...mapBookingToMutationInput(bookingInput), locationUrl }, callbacks); } // Clears form values stored in store, so old values won't stick around. setFormValues({});