fix: skip confirm step followup (#19076)

* Fix: Resolved double-click issue on timeSlot in Booker Atom

* fix: show toast on booking error

* fix: type error

* Prevent the automatic change of bookerState when all fields are filled.

* fix: when a user clicks on a new timeslot, any previously displayed "Confirm" button should be hidden

* Update useSkipConfirmStep.ts

* fix: remove 2 states for slots

* Update useSlotsForDate.ts

* fix types

* use common types

* fix: show VerifyCodeDialog and RedirectToInstantMeetingModal when skiping confirm step

* fix for instant bookings

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Somay Chauhan
2025-02-21 19:07:54 +05:30
committed by GitHub
co-authored by Udit Takkar
parent 07accf700e
commit 829bb63eb9
11 changed files with 174 additions and 139 deletions
+27 -27
View File
@@ -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 ? (
<VerifyCodeDialog
isOpenDialog={isEmailVerificationModalVisible}
setIsOpenDialog={setEmailVerificationModalVisible}
email={formEmail}
isUserSessionRequiredToVerify={false}
verifyCodeWithSessionNotRequired={verifyCode.verifyCodeWithSessionNotRequired}
verifyCodeWithSessionRequired={verifyCode.verifyCodeWithSessionRequired}
error={verifyCode.error}
resetErrors={verifyCode.resetErrors}
isPending={verifyCode.isPending}
setIsPending={verifyCode.setIsPending}
/>
) : (
<></>
)}
{!isPlatform && (
<RedirectToInstantMeetingModal
expiryTime={expiryTime}
@@ -253,26 +243,17 @@ const BookerComponent = ({
event,
expiryTime,
extraOptions,
formEmail,
formErrors,
handleBookEvent,
handleVerifyEmail,
isEmailVerificationModalVisible,
key,
loadingStates,
onGoBackInstantMeeting,
renderConfirmNotVerifyEmailButtonCond,
rescheduleUid,
seatedEventData,
setEmailVerificationModalVisible,
setSeatedEventData,
setSelectedTimeslot,
verifyCode?.error,
verifyCode?.isPending,
verifyCode?.resetErrors,
verifyCode?.setIsPending,
verifyCode?.verifyCodeWithSessionNotRequired,
verifyCode?.verifyCodeWithSessionRequired,
isPlatform,
shouldRenderCaptcha,
isVerificationCodeSending,
@@ -532,6 +513,25 @@ const BookerComponent = ({
)}
</div>
<>
{verifyCode && formEmail ? (
<VerifyCodeDialog
isOpenDialog={isEmailVerificationModalVisible}
setIsOpenDialog={setEmailVerificationModalVisible}
email={formEmail}
isUserSessionRequiredToVerify={false}
verifyCodeWithSessionNotRequired={verifyCode.verifyCodeWithSessionNotRequired}
verifyCodeWithSessionRequired={verifyCode.verifyCodeWithSessionRequired}
error={verifyCode.error}
resetErrors={verifyCode.resetErrors}
isPending={verifyCode.isPending}
setIsPending={verifyCode.setIsPending}
/>
) : (
<></>
)}
</>
<BookFormAsModal
onCancel={() => setSelectedTimeslot(null)}
visible={bookerState === "booking" && shouldShowFormInDialog}>
@@ -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<HTMLDivElement | null>(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}
/>
</div>
@@ -124,7 +124,6 @@ export const BookEventForm = ({
/>
</div>
)}
{/* Cloudflare Turnstile Captcha */}
{!isPlatform && (
<div className="text-subtle my-3 w-full text-xs">
<Trans
@@ -3,10 +3,14 @@ import { useState, useEffect } from "react";
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { getBookingResponsesSchemaWithOptionalChecks } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import type { BookerEvent } from "@calcom/features/bookings/types";
import type { BookerEvent } from "../../../types";
import type { BookerState } from "../../types";
const useSkipConfirmStep = (
bookingForm: UseBookingFormReturnType["bookingForm"],
bookerState: BookerState,
isInstantMeeting: boolean,
bookingFields?: BookerEvent["bookingFields"]
) => {
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;
};
@@ -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<SlotItemProps, "slot">;
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 (
<AnimatePresence>
<div className="flex gap-2">
@@ -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 = ({
</p>
)}
</Button>
{showConfirm && (
{!!slot.showConfirmButton && (
<HoverCard.Root>
<HoverCard.Trigger asChild>
<m.div initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
{skipConfirmStep ? (
<Button
type="button"
onClick={() =>
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
}
data-testid="skip-confirm-book-button"
disabled={
(!!shouldRenderCaptcha && !watchedCfToken) ||
loadingStates?.creatingBooking ||
loadingStates?.creatingRecurringBooking ||
isVerificationCodeSending ||
loadingStates?.creatingInstantBooking
}
color="primary"
loading={
(selectedTimeslot === slot.time && loadingStates?.creatingBooking) ||
loadingStates?.creatingRecurringBooking ||
isVerificationCodeSending ||
loadingStates?.creatingInstantBooking
}>
{renderConfirmNotVerifyEmailButtonCond
? isPaidEvent
? t("pay_and_book")
: t("confirm")
: t("verify_email_email_button")}
</Button>
) : (
<Button
variant={layout === "column_view" ? "icon" : "button"}
StartIcon={layout === "column_view" ? "chevron-right" : undefined}
onClick={() =>
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
}>
{layout !== "column_view" && t("confirm")}
</Button>
)}
<m.div key={slot.time} initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
<Button
variant={layout === "column_view" ? "icon" : "button"}
StartIcon={layout === "column_view" ? "chevron-right" : undefined}
type="button"
onClick={() =>
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
}
data-testid="skip-confirm-book-button"
disabled={
(!!shouldRenderCaptcha && !watchedCfToken) ||
loadingStates?.creatingBooking ||
loadingStates?.creatingRecurringBooking ||
isVerificationCodeSending ||
loadingStates?.creatingInstantBooking
}
color="primary"
loading={
(selectedTimeslot === slot.time && loadingStates?.creatingBooking) ||
loadingStates?.creatingRecurringBooking ||
isVerificationCodeSending ||
loadingStates?.creatingInstantBooking
}>
{layout == "column_view"
? ""
: renderConfirmNotVerifyEmailButtonCond
? isPaidEvent
? t("pay_and_book")
: t("confirm")
: t("verify_email_email_button")}
</Button>
</m.div>
</HoverCard.Trigger>
{isOverlapping && (
+1 -1
View File
@@ -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";
@@ -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";
@@ -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"];
@@ -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;
};
@@ -521,8 +521,8 @@ export const BookerPlatformWrapper = (
onOverlaySwitchStateChange={onOverlaySwitchStateChange}
extraOptions={extraOptions ?? {}}
bookings={{
handleBookEvent: () => {
handleBookEvent();
handleBookEvent: (timeSlot?: string) => {
handleBookEvent(timeSlot);
return;
},
expiryTime: undefined,
@@ -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<string, string>;
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({});