feat: skip confirm step in booker (#18773)

* feat: skip confirm step

* better naming

* disable on loading

* feat: added cloudflare turnstile captcha to booker

* Update Booker.tsx

* Update AvailableTimeSlots.tsx

* made optional to fix type errors

* Update Booker.tsx

* Update getBookingResponsesSchema.ts

* Update Booker.tsx

* fixed failing tests

* added tests

* fix: fixed failing embed tests
This commit is contained in:
Somay Chauhan
2025-01-29 12:56:23 -03:00
committed by GitHub
parent ac8b4a2861
commit be1b9d1a79
10 changed files with 251 additions and 114 deletions
+17
View File
@@ -401,6 +401,23 @@ test.describe("prefill", () => {
await expect(page.locator('[name="email"]')).toHaveValue(testEmail);
});
});
test("skip confirm step if all fields are prefilled from query params", async ({ page }) => {
await page.goto("/pro/30min");
const url = new URL(page.url());
url.searchParams.set("name", testName);
url.searchParams.set("email", testEmail);
url.searchParams.set("guests", "guest1@example.com");
url.searchParams.set("guests", "guest2@example.com");
url.searchParams.set("notes", "This is an additional note");
await page.goto(url.toString());
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page.locator('[data-testid="skip-confirm-book-button"]')).toBeVisible();
await page.click('[data-testid="skip-confirm-book-button"]');
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
});
test.describe("Booking on different layouts", () => {
@@ -172,7 +172,6 @@ test.describe("Manage Booking Questions", () => {
prefillUrl.searchParams.append("email", "john@example.com");
prefillUrl.searchParams.append("guests", "guest1@example.com");
prefillUrl.searchParams.append("guests", "guest2@example.com");
prefillUrl.searchParams.append("notes", "This is an additional note");
await page.goto(prefillUrl.toString());
await bookTimeSlot({ page, skipSubmission: true });
await expectSystemFieldsToBeThereOnBookingPage({
@@ -185,7 +184,6 @@ test.describe("Manage Booking Questions", () => {
},
email: "john@example.com",
guests: ["guest1@example.com", "guest2@example.com"],
notes: "This is an additional note",
},
});
});
-2
View File
@@ -63,7 +63,6 @@ if (only === "all" || only === "ns:default") {
},
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
theme: "dark",
"flag.coep": "true",
@@ -454,7 +453,6 @@ if (only === "all" || only == "ns:floatingButton") {
"flag.coep": "true",
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
...(theme ? { theme } : {}),
},
+50 -5
View File
@@ -8,9 +8,11 @@ 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";
@@ -33,6 +35,8 @@ 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(() =>
@@ -78,6 +82,7 @@ const BookerComponent = ({
const searchParams = useCompatSearchParams();
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
const {
shouldShowFormInDialog,
@@ -128,6 +133,8 @@ const BookerComponent = ({
const { handleBookEvent, errors, loadingStates, expiryTime, instantVideoMeetingUrl } = bookings;
const watchedCfToken = bookingForm.watch("cfToken");
const {
isEmailVerificationModalVisible,
setEmailVerificationModalVisible,
@@ -153,29 +160,46 @@ const BookerComponent = ({
}
};
const skipConfirmStep = useSkipConfirmStep(bookingForm, 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");
if (!selectedTimeslot || skipConfirmStep) return setBookerState("selecting_time");
return setBookerState("booking");
}, [event, selectedDate, selectedTimeslot, setBookerState]);
}, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep]);
const slot = getQueryParam("slot");
useEffect(() => {
setSelectedTimeslot(slot || null);
}, [slot, setSelectedTimeslot]);
const onSubmit = (timeSlot?: string) => {
renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail();
};
const EventBooker = useMemo(() => {
return bookerState === "booking" ? (
<BookEventForm
key={key}
renderCaptcha={renderCaptcha}
shouldRenderCaptcha={shouldRenderCaptcha}
onCancel={() => {
setSelectedTimeslot(null);
if (seatedEventData.bookingUid) {
setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined });
}
}}
onSubmit={renderConfirmNotVerifyEmailButtonCond ? handleBookEvent : handleVerifyEmail}
onSubmit={() => (renderConfirmNotVerifyEmailButtonCond ? handleBookEvent() : handleVerifyEmail())}
errorRef={bookerFormErrorRef}
errors={{ ...formErrors, ...errors }}
loadingStates={loadingStates}
@@ -249,6 +273,8 @@ const BookerComponent = ({
verifyCode?.verifyCodeWithSessionNotRequired,
verifyCode?.verifyCodeWithSessionRequired,
isPlatform,
shouldRenderCaptcha,
isVerificationCodeSending,
]);
/**
@@ -442,6 +468,13 @@ const BookerComponent = ({
seatsPerTimeSlot={event.data?.seatsPerTimeSlot}
showAvailableSeatsCount={event.data?.seatsShowAvailabilityCount}
event={event}
loadingStates={loadingStates}
renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond}
isVerificationCodeSending={isVerificationCodeSending}
onSubmit={onSubmit}
skipConfirmStep={skipConfirmStep}
shouldRenderCaptcha={shouldRenderCaptcha}
watchedCfToken={watchedCfToken}
/>
</BookerSection>
</AnimatePresence>
@@ -472,7 +505,19 @@ const BookerComponent = ({
/>
</div>
)}
{!hideBranding && (!isPlatform || isPlatformBookerEmbed) && (
{shouldRenderCaptcha && (
<div className="mb-6 mt-auto pt-6">
<TurnstileCaptcha
appearance="interaction-only"
onVerify={(token) => {
bookingForm.setValue("cfToken", token);
}}
/>
</div>
)}
{!hideBranding && (!isPlatform || isPlatformBookerEmbed) && !shouldRenderCaptcha && (
<m.span
key="logo"
className={classNames(
@@ -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<BookerEvent, "length"> | null;
data?: Pick<BookerEvent, "length" | "bookingFields" | "price" | "currency" | "metadata"> | null;
};
customClassNames?: {
availableTimeSlotsContainer?: string;
@@ -29,6 +30,13 @@ type AvailableTimeSlotsProps = {
availableTimeSlotsTimeFormatToggle?: string;
availableTimes?: string;
};
loadingStates: IUseBookingLoadingStates;
isVerificationCodeSending: boolean;
renderConfirmNotVerifyEmailButtonCond: boolean;
onSubmit: (timeSlot?: string) => void;
skipConfirmStep: boolean;
shouldRenderCaptcha?: boolean;
watchedCfToken?: string;
};
/**
@@ -38,15 +46,17 @@ type AvailableTimeSlotsProps = {
* will also fetch the next `extraDays` days and show multiple days
* in columns next to each other.
*/
export const AvailableTimeSlots = ({
extraDays,
limitHeight,
seatsPerTimeSlot,
showAvailableSeatsCount,
schedule,
isLoading,
event,
customClassNames,
skipConfirmStep,
onSubmit,
...props
}: AvailableTimeSlotsProps) => {
const selectedDate = useBookerStore((state) => state.selectedDate);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
@@ -71,6 +81,9 @@ export const AvailableTimeSlots = ({
showAvailableSeatsCount,
});
}
if (skipConfirmStep) {
onSubmit(time);
}
return;
};
@@ -135,9 +148,9 @@ export const AvailableTimeSlots = ({
showTimeFormatToggle={!isColumnView}
onTimeSelect={onTimeSelect}
slots={slots.slots}
seatsPerTimeSlot={seatsPerTimeSlot}
showAvailableSeatsCount={showAvailableSeatsCount}
event={event}
skipConfirmStep={skipConfirmStep}
{...props}
/>
</div>
))}
@@ -1,18 +1,12 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useMemo, useState } from "react";
import type { FieldError } from "react-hook-form";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/monorepo";
import type { BookerEvent } from "@calcom/features/bookings/types";
import {
WEBSITE_PRIVACY_POLICY_URL,
WEBSITE_TERMS_URL,
CLOUDFLARE_SITE_ID,
CLOUDFLARE_USE_TURNSTILE_IN_BOOKER,
} from "@calcom/lib/constants";
import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, EmptyScreen, Form } from "@calcom/ui";
@@ -23,8 +17,6 @@ import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBo
import { BookingFields } from "./BookingFields";
import { FormSkeleton } from "./Skeleton";
const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false });
type BookEventFormProps = {
onCancel?: () => void;
onSubmit: () => void;
@@ -37,7 +29,7 @@ type BookEventFormProps = {
extraOptions: Record<string, string | string[]>;
isPlatform?: boolean;
isVerificationCodeSending: boolean;
renderCaptcha?: boolean;
shouldRenderCaptcha?: boolean;
};
export const BookEventForm = ({
@@ -54,7 +46,7 @@ export const BookEventForm = ({
extraOptions,
isVerificationCodeSending,
isPlatform = false,
renderCaptcha,
shouldRenderCaptcha,
}: Omit<BookEventFormProps, "event"> & {
eventQuery: {
isError: boolean;
@@ -71,13 +63,6 @@ export const BookEventForm = ({
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
// Cloudflare Turnstile Captcha
const shouldRenderCaptcha =
!process.env.NEXT_PUBLIC_IS_E2E &&
renderCaptcha &&
CLOUDFLARE_SITE_ID &&
CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1";
const [responseVercelIdHeader] = useState<string | null>(null);
const { t } = useLocale();
@@ -140,14 +125,6 @@ export const BookEventForm = ({
</div>
)}
{/* Cloudflare Turnstile Captcha */}
{shouldRenderCaptcha ? (
<TurnstileCaptcha
appearance="interaction-only"
onVerify={(token) => {
bookingForm.setValue("cfToken", token);
}}
/>
) : null}
{!isPlatform && (
<div className="text-subtle my-3 w-full text-xs">
<Trans
@@ -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, rescheduleUid]);
return canSkip;
};
export default useSkipConfirmStep;
@@ -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,28 @@ 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<SlotItemProps, "slot">;
type SlotItemProps = {
slot: Slots[string][number];
seatsPerTimeSlot?: number | null;
selectedSlots?: string[];
onTimeSelect: TOnTimeSelect;
showAvailableSeatsCount?: boolean | null;
event: {
data?: Pick<BookerEvent, "length"> | null;
data?: Pick<BookerEvent, "length" | "bookingFields" | "price" | "currency" | "metadata"> | null;
};
customClassNames?: string;
loadingStates?: IUseBookingLoadingStates;
isVerificationCodeSending?: boolean;
renderConfirmNotVerifyEmailButtonCond?: boolean;
skipConfirmStep?: boolean;
shouldRenderCaptcha?: boolean;
watchedCfToken?: string;
};
const SlotItem = ({
@@ -50,25 +62,29 @@ const SlotItem = ({
showAvailableSeatsCount,
event,
customClassNames,
}: {
slot: Slots[string][number];
seatsPerTimeSlot?: number | null;
selectedSlots?: string[];
onTimeSelect: TOnTimeSelect;
showAvailableSeatsCount?: boolean | null;
event: {
data?: Pick<BookerEvent, "length"> | null;
};
customClassNames?: string;
}) => {
loadingStates,
renderConfirmNotVerifyEmailButtonCond,
isVerificationCodeSending,
skipConfirmStep,
shouldRenderCaptcha,
watchedCfToken,
}: 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);
@@ -82,35 +98,32 @@ const SlotItem = ({
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay({
start: computedDateWithUsersTimezone,
selectedDuration: eventData?.length ?? 0,
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 && ((overlayCalendarToggled && isOverlapping) || skipConfirmStep)) {
setShowConfirm(true);
return;
}
if (isOverlapping) {
setOverlapConfirm(true);
return;
}
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
}, [
overlayCalendarToggled,
isOverlapping,
overlapConfirm,
showConfirm,
onTimeSelect,
slot.time,
slot?.attendees,
slot.bookingUid,
seatsPerTimeSlot,
skipConfirmStep,
]);
return (
@@ -118,7 +131,15 @@ const SlotItem = ({
<div className="flex gap-2">
<Button
key={slot.time}
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
disabled={
bookingFull ||
!!(slot.bookingUid && slot.bookingUid === bookingData?.uid) ||
loadingStates?.creatingBooking ||
loadingStates?.creatingRecurringBooking ||
isVerificationCodeSending ||
loadingStates?.creatingInstantBooking ||
(skipConfirmStep && !!shouldRenderCaptcha && !watchedCfToken)
}
data-testid="time"
data-disabled={bookingFull}
data-time={slot.time}
@@ -155,32 +176,63 @@ const SlotItem = ({
</p>
)}
</Button>
{overlapConfirm && isOverlapping && (
{showConfirm && (
<HoverCard.Root>
<HoverCard.Trigger asChild>
<m.div initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
<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>
{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>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content side="top" align="end" sideOffset={2}>
<div className="text-emphasis bg-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
<div className="flex items-center gap-2">
<p>Busy</p>
{isOverlapping && (
<HoverCard.Portal>
<HoverCard.Content side="top" align="end" sideOffset={2}>
<div className="text-emphasis bg-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
<div className="flex items-center gap-2">
<p>Busy</p>
</div>
<p className="text-muted">
{overlappingTimeStart} - {overlappingTimeEnd}
</p>
</div>
<p className="text-muted">
{overlappingTimeStart} - {overlappingTimeEnd}
</p>
</div>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Content>
</HoverCard.Portal>
)}
</HoverCard.Root>
)}
</div>
@@ -190,14 +242,9 @@ const SlotItem = ({
export const AvailableTimes = ({
slots,
onTimeSelect,
seatsPerTimeSlot,
showAvailableSeatsCount,
showTimeFormatToggle = true,
className,
selectedSlots,
event,
customClassNames,
...props
}: AvailableTimesProps) => {
const { t } = useLocale();
@@ -226,18 +273,7 @@ export const AvailableTimes = ({
{oooBeforeSlots && !oooAfterSlots && <OOOSlot {...slots[0]} />}
{slots.map((slot) => {
if (slot.away) return null;
return (
<SlotItem
customClassNames={customClassNames}
key={slot.time}
onTimeSelect={onTimeSelect}
slot={slot}
selectedSlots={selectedSlots}
seatsPerTimeSlot={seatsPerTimeSlot}
showAvailableSeatsCount={showAvailableSeatsCount}
event={event}
/>
);
return <SlotItem key={slot.time} slot={slot} {...props} />;
})}
{oooAfterSlots && !oooBeforeSlots && <OOOSlot {...slots[slots.length - 1]} className="pb-0" />}
</div>
@@ -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<T extends z.ZodType>({
@@ -41,12 +47,14 @@ function preprocess<T extends z.ZodType>({
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<T>, z.infer<T>, z.infer<T>> {
const preprocessed = z.preprocess(
(responses) => {
@@ -149,8 +157,11 @@ function preprocess<T extends z.ZodType>({
if (bookingField.hideWhenJustOneOption) {
hidden = hidden || numOptions <= 1;
}
let isRequired = false;
// If the field is hidden, then it can never be required
const isRequired = hidden ? false : isFieldApplicableToCurrentView ? bookingField.required : false;
if (!hidden && isFieldApplicableToCurrentView) {
isRequired = checkOptional || !!bookingField.required;
}
if ((isPartialSchema || !isRequired) && value === undefined) {
continue;
@@ -162,7 +173,7 @@ function preprocess<T extends z.ZodType>({
}
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 +296,7 @@ function preprocess<T extends z.ZodType>({
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;
@@ -39,7 +39,7 @@ export const useHandleBookEvent = ({
routingFormSearchParams,
}: 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);
@@ -55,8 +55,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({});