From 203b38c3e6967de99650fd9e4ce23f8912c00dc3 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Mon, 20 Jan 2025 19:14:37 +0530 Subject: [PATCH 01/13] feat: skip confirm step --- packages/features/bookings/Booker/Booker.tsx | 18 ++- .../Booker/components/AvailableTimeSlots.tsx | 17 +- .../components/hooks/useSkipConfirmStep.ts | 43 ++++++ .../bookings/components/AvailableTimes.tsx | 146 ++++++++++-------- .../bookings/lib/getBookingResponsesSchema.ts | 22 ++- .../hooks/bookings/useHandleBookEvent.ts | 5 +- 6 files changed, 175 insertions(+), 76 deletions(-) create mode 100644 packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 4cd6355e6e2c4d..6ddf67f81815b1 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -8,6 +8,7 @@ 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"; @@ -151,17 +152,24 @@ const BookerComponent = ({ } }; + const skipEventBooker = useSkipConfirmStep(bookingForm, event?.data?.bookingFields); + useEffect(() => { if (event.isPending) return setBookerState("loading"); if (!selectedDate) return setBookerState("selecting_date"); - if (!selectedTimeslot) return setBookerState("selecting_time"); + if (!selectedTimeslot || skipEventBooker) return setBookerState("selecting_time"); return setBookerState("booking"); - }, [event, selectedDate, selectedTimeslot, setBookerState]); + }, [event, selectedDate, selectedTimeslot, setBookerState, skipEventBooker]); const slot = getQueryParam("slot"); useEffect(() => { setSelectedTimeslot(slot || null); }, [slot, setSelectedTimeslot]); + + const onSubmit = (timeSlot?: string) => { + renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail(); + }; + const EventBooker = useMemo(() => { return bookerState === "booking" ? ( diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index 574bb11536038f..102253010d82db 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -2,6 +2,7 @@ import { 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 { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate"; @@ -20,7 +21,7 @@ type AvailableTimeSlotsProps = { seatsPerTimeSlot?: number | null; showAvailableSeatsCount?: boolean | null; event: { - data?: Pick | null; + data?: Pick | null; }; customClassNames?: { availableTimeSlotsContainer?: string; @@ -29,6 +30,11 @@ type AvailableTimeSlotsProps = { availableTimeSlotsTimeFormatToggle?: string; availableTimes?: string; }; + loadingStates: IUseBookingLoadingStates; + isVerificationCodeSending: boolean; + renderConfirmNotVerifyEmailButtonCond: boolean; + onSubmit: (timeSlot?: string) => void; + skipEventBooker: boolean; }; /** @@ -38,6 +44,7 @@ type AvailableTimeSlotsProps = { * will also fetch the next `extraDays` days and show multiple days * in columns next to each other. */ + export const AvailableTimeSlots = ({ extraDays, limitHeight, @@ -47,6 +54,9 @@ export const AvailableTimeSlots = ({ isLoading, event, customClassNames, + skipEventBooker, + onSubmit, + ...props }: AvailableTimeSlotsProps) => { const selectedDate = useBookerStore((state) => state.selectedDate); const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); @@ -71,6 +81,9 @@ export const AvailableTimeSlots = ({ showAvailableSeatsCount, }); } + if (skipEventBooker) { + onSubmit(time); + } return; }; @@ -138,6 +151,8 @@ export const AvailableTimeSlots = ({ seatsPerTimeSlot={seatsPerTimeSlot} showAvailableSeatsCount={showAvailableSeatsCount} event={event} + skipEventBooker={skipEventBooker} + {...props} /> ))} diff --git a/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts b/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts new file mode 100644 index 00000000000000..d442f92ce30b1a --- /dev/null +++ b/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts @@ -0,0 +1,43 @@ +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"; + +const useSkipConfirmStep = ( + bookingForm: UseBookingFormReturnType["bookingForm"], + bookingFields?: BookerEvent["bookingFields"] +) => { + const bookingFormValues = bookingForm.getValues(); + + const [canSkip, setCanSkip] = useState(false); + const rescheduleUid = useBookerStore((state) => state.rescheduleUid); + + useEffect(() => { + const checkSkipStep = async () => { + if (!bookingFields) { + setCanSkip(false); + return; + } + + try { + const responseSchema = getBookingResponsesSchemaWithOptionalChecks({ + bookingFields, + view: rescheduleUid ? "reschedule" : "booking", + }); + const responseSafeParse = await responseSchema.safeParseAsync(bookingFormValues.responses); + + setCanSkip(responseSafeParse.success); + } catch (error) { + setCanSkip(false); + } + }; + + checkSkipStep(); + }, [bookingFormValues, bookingFields]); + + return canSkip; +}; + +export default useSkipConfirmStep; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 61cb28ec0e5af8..a872855c840348 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -1,15 +1,17 @@ // 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, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useIsPlatform } from "@calcom/atoms/monorepo"; import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability"; 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 { classNames } from "@calcom/lib"; +import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; import type { IGetAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util"; @@ -28,18 +30,26 @@ type TOnTimeSelect = ( bookingUid?: string ) => void; -type AvailableTimesProps = { +export type AvailableTimesProps = { slots: IGetAvailableSlots["slots"][string]; - onTimeSelect: TOnTimeSelect; - seatsPerTimeSlot?: number | null; - showAvailableSeatsCount?: boolean | null; showTimeFormatToggle?: boolean; className?: string; +} & Omit; + +type SlotItemProps = { + slot: Slots[string][number]; + seatsPerTimeSlot?: number | null; selectedSlots?: string[]; + onTimeSelect: TOnTimeSelect; + showAvailableSeatsCount?: boolean | null; event: { - data?: Pick | null; + data?: Pick | null; }; customClassNames?: string; + loadingStates: IUseBookingLoadingStates; + isVerificationCodeSending: boolean; + renderConfirmNotVerifyEmailButtonCond: boolean; + skipEventBooker: boolean; }; const SlotItem = ({ @@ -50,25 +60,26 @@ const SlotItem = ({ showAvailableSeatsCount, event, customClassNames, -}: { - slot: Slots[string][number]; - seatsPerTimeSlot?: number | null; - selectedSlots?: string[]; - onTimeSelect: TOnTimeSelect; - showAvailableSeatsCount?: boolean | null; - event: { - data?: Pick | null; - }; - customClassNames?: string; -}) => { + loadingStates, + renderConfirmNotVerifyEmailButtonCond, + isVerificationCodeSending, + skipEventBooker, +}: SlotItemProps) => { const { t } = useLocale(); + const { data: eventData } = event; + + const isPaidEvent = useMemo(() => { + if (!eventData?.price) return false; + const paymentAppData = getPaymentAppData(eventData); + return eventData?.price > 0 && !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0; + }, [eventData]); + const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault"); const { timeFormat, timezone } = useBookerTime(); const bookingData = useBookerStore((state) => state.bookingData); const layout = useBookerStore((state) => state.layout); - const { data: eventData } = event; const hasTimeSlots = !!seatsPerTimeSlot; const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone); @@ -88,29 +99,25 @@ const SlotItem = ({ offset, }); - const [overlapConfirm, setOverlapConfirm] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); const onButtonClick = useCallback(() => { - if (!overlayCalendarToggled || (isOverlapping && overlapConfirm)) { - onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); + if (!showConfirm && (isOverlapping || skipEventBooker)) { + setShowConfirm(true); return; } - - if (isOverlapping) { - setOverlapConfirm(true); - return; - } - + setShowConfirm(false); onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); }, [ overlayCalendarToggled, isOverlapping, - overlapConfirm, + showConfirm, onTimeSelect, slot.time, slot?.attendees, slot.bookingUid, seatsPerTimeSlot, + skipEventBooker, ]); return ( @@ -155,32 +162,55 @@ const SlotItem = ({

)} - {overlapConfirm && isOverlapping && ( + {showConfirm && ( - + {skipEventBooker ? ( + + ) : ( + + )} - - -
-
-

Busy

+ {isOverlapping && ( + + +
+
+

Busy

+
+

+ {overlappingTimeStart} - {overlappingTimeEnd} +

-

- {overlappingTimeStart} - {overlappingTimeEnd} -

-
- - + + + )} )}
@@ -190,14 +220,9 @@ const SlotItem = ({ export const AvailableTimes = ({ slots, - onTimeSelect, - seatsPerTimeSlot, - showAvailableSeatsCount, showTimeFormatToggle = true, className, - selectedSlots, - event, - customClassNames, + ...props }: AvailableTimesProps) => { const { t } = useLocale(); @@ -226,18 +251,7 @@ export const AvailableTimes = ({ {oooBeforeSlots && !oooAfterSlots && } {slots.map((slot) => { if (slot.away) return null; - return ( - - ); + return ; })} {oooAfterSlots && !oooBeforeSlots && } diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 9961ffb8df26b5..0abc554b786ad7 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -34,6 +34,12 @@ export default function getBookingResponsesSchema({ bookingFields, view }: Commo return preprocess({ schema, bookingFields, isPartialSchema: false, view }); } +// Should be used when we want to check if the optional fields are entered and valid as well +export function getBookingResponsesSchemaWithOptionalChecks({ bookingFields, view }: CommonParams) { + const schema = bookingResponses.and(z.record(z.any())); + return preprocess({ schema, bookingFields, isPartialSchema: false, view, checkOptional: true }); +} + // TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder // It allows anyone using FormBuilder to get the same preprocessing automatically function preprocess({ @@ -41,12 +47,14 @@ function preprocess({ bookingFields, isPartialSchema, view: currentView, + checkOptional = false, }: CommonParams & { schema: T; // It is useful when we want to prefill the responses with the partial values. Partial can be in 2 ways // - Not all required fields are need to be provided for prefill. // - Even a field response itself can be partial so the content isn't validated e.g. a field with type="phone" can be given a partial phone number(e.g. Specifying the country code like +91) isPartialSchema: boolean; + checkOptional?: boolean; }): z.ZodType, z.infer, z.infer> { const preprocessed = z.preprocess( (responses) => { @@ -150,7 +158,13 @@ function preprocess({ hidden = hidden || numOptions <= 1; } // If the field is hidden, then it can never be required - const isRequired = hidden ? false : isFieldApplicableToCurrentView ? bookingField.required : false; + const isRequired = hidden + ? false + : isFieldApplicableToCurrentView + ? checkOptional + ? true + : bookingField.required + : false; if ((isPartialSchema || !isRequired) && value === undefined) { continue; @@ -162,7 +176,7 @@ function preprocess({ } if (bookingField.type === "email") { - if (!bookingField.hidden && bookingField.required) { + if (!bookingField.hidden && checkOptional ? true : bookingField.required) { // Email RegExp to validate if the input is a valid email if (!emailSchema.safeParse(value).success) { ctx.addIssue({ @@ -285,9 +299,7 @@ function preprocess({ const typeOfOptionInput = optionField?.type; if ( // Either the field is required or there is a radio selected, we need to check if the optionInput is required or not. - (isRequired || value?.value) && - optionField?.required && - !optionValue + (isRequired || value?.value) && checkOptional ? true : optionField?.required && !optionValue ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("error_required_field") }); return; diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index 0dbcbbcaf5dcfd..d2a763edc769b1 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -36,7 +36,7 @@ export const useHandleBookEvent = ({ locationUrl, }: UseHandleBookingProps) => { const setFormValues = useBookerStore((state) => state.setFormValues); - const timeslot = useBookerStore((state) => state.selectedTimeslot); + const storeTimeSlot = useBookerStore((state) => state.selectedTimeslot); const duration = useBookerStore((state) => state.selectedDuration); const { timezone } = useBookerTime(); const rescheduleUid = useBookerStore((state) => state.rescheduleUid); @@ -52,8 +52,9 @@ export const useHandleBookEvent = ({ const crmOwnerRecordType = useBookerStore((state) => state.crmOwnerRecordType); const crmAppSlug = useBookerStore((state) => state.crmAppSlug); - const handleBookEvent = () => { + const handleBookEvent = (inputTimeSlot?: string) => { const values = bookingForm.getValues(); + const timeslot = inputTimeSlot ?? storeTimeSlot; if (timeslot) { // Clears form values stored in store, so old values won't stick around. setFormValues({}); From 9290e436dcd0a5161b638120fa57105bbe0ae92e Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Tue, 21 Jan 2025 09:01:00 +0530 Subject: [PATCH 02/13] better naming --- packages/features/bookings/Booker/Booker.tsx | 9 ++++----- .../Booker/components/AvailableTimeSlots.tsx | 12 ++++-------- .../Booker/components/hooks/useSkipConfirmStep.ts | 2 +- .../features/bookings/components/AvailableTimes.tsx | 10 +++++----- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 6ddf67f81815b1..88c01108675624 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -152,14 +152,14 @@ const BookerComponent = ({ } }; - const skipEventBooker = useSkipConfirmStep(bookingForm, event?.data?.bookingFields); + const skipConfirmStep = useSkipConfirmStep(bookingForm, event?.data?.bookingFields); useEffect(() => { if (event.isPending) return setBookerState("loading"); if (!selectedDate) return setBookerState("selecting_date"); - if (!selectedTimeslot || skipEventBooker) return setBookerState("selecting_time"); + if (!selectedTimeslot || skipConfirmStep) return setBookerState("selecting_time"); return setBookerState("booking"); - }, [event, selectedDate, selectedTimeslot, setBookerState, skipEventBooker]); + }, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep]); const slot = getQueryParam("slot"); useEffect(() => { @@ -254,7 +254,6 @@ const BookerComponent = ({ verifyCode?.verifyCodeWithSessionNotRequired, verifyCode?.verifyCodeWithSessionRequired, isPlatform, - onSubmit, ]); /** @@ -451,7 +450,7 @@ const BookerComponent = ({ renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond} isVerificationCodeSending={isVerificationCodeSending} onSubmit={onSubmit} - skipEventBooker={skipEventBooker} + skipConfirmStep={skipConfirmStep} /> diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index 102253010d82db..6ee9cddae01467 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -34,7 +34,7 @@ type AvailableTimeSlotsProps = { isVerificationCodeSending: boolean; renderConfirmNotVerifyEmailButtonCond: boolean; onSubmit: (timeSlot?: string) => void; - skipEventBooker: boolean; + skipConfirmStep: boolean; }; /** @@ -48,13 +48,11 @@ type AvailableTimeSlotsProps = { export const AvailableTimeSlots = ({ extraDays, limitHeight, - seatsPerTimeSlot, showAvailableSeatsCount, schedule, isLoading, - event, customClassNames, - skipEventBooker, + skipConfirmStep, onSubmit, ...props }: AvailableTimeSlotsProps) => { @@ -81,7 +79,7 @@ export const AvailableTimeSlots = ({ showAvailableSeatsCount, }); } - if (skipEventBooker) { + if (skipConfirmStep) { onSubmit(time); } return; @@ -148,10 +146,8 @@ export const AvailableTimeSlots = ({ showTimeFormatToggle={!isColumnView} onTimeSelect={onTimeSelect} slots={slots.slots} - seatsPerTimeSlot={seatsPerTimeSlot} showAvailableSeatsCount={showAvailableSeatsCount} - event={event} - skipEventBooker={skipEventBooker} + skipConfirmStep={skipConfirmStep} {...props} /> diff --git a/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts b/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts index d442f92ce30b1a..762b8d02fff868 100644 --- a/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts +++ b/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts @@ -35,7 +35,7 @@ const useSkipConfirmStep = ( }; checkSkipStep(); - }, [bookingFormValues, bookingFields]); + }, [bookingFormValues, bookingFields, rescheduleUid]); return canSkip; }; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index a872855c840348..1fb6148530d0f8 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -49,7 +49,7 @@ type SlotItemProps = { loadingStates: IUseBookingLoadingStates; isVerificationCodeSending: boolean; renderConfirmNotVerifyEmailButtonCond: boolean; - skipEventBooker: boolean; + skipConfirmStep: boolean; }; const SlotItem = ({ @@ -63,7 +63,7 @@ const SlotItem = ({ loadingStates, renderConfirmNotVerifyEmailButtonCond, isVerificationCodeSending, - skipEventBooker, + skipConfirmStep, }: SlotItemProps) => { const { t } = useLocale(); @@ -102,7 +102,7 @@ const SlotItem = ({ const [showConfirm, setShowConfirm] = useState(false); const onButtonClick = useCallback(() => { - if (!showConfirm && (isOverlapping || skipEventBooker)) { + if (!showConfirm && (isOverlapping || skipConfirmStep)) { setShowConfirm(true); return; } @@ -117,7 +117,7 @@ const SlotItem = ({ slot?.attendees, slot.bookingUid, seatsPerTimeSlot, - skipEventBooker, + skipConfirmStep, ]); return ( @@ -166,7 +166,7 @@ const SlotItem = ({ - {skipEventBooker ? ( + {skipConfirmStep ? (