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:
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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({});
|
||||
|
||||
Reference in New Issue
Block a user