Skip to content

Commit

Permalink
feat: skip confirm step in booker (calcom#18773)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SomayChauhan authored Jan 29, 2025
1 parent ac8b4a2 commit be1b9d1
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 114 deletions.
17 changes: 17 additions & 0 deletions apps/web/playwright/booking-pages.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "[email protected]");
url.searchParams.set("guests", "[email protected]");
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", () => {
Expand Down
2 changes: 0 additions & 2 deletions apps/web/playwright/manage-booking-questions.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ test.describe("Manage Booking Questions", () => {
prefillUrl.searchParams.append("email", "[email protected]");
prefillUrl.searchParams.append("guests", "[email protected]");
prefillUrl.searchParams.append("guests", "[email protected]");
prefillUrl.searchParams.append("notes", "This is an additional note");
await page.goto(prefillUrl.toString());
await bookTimeSlot({ page, skipSubmission: true });
await expectSystemFieldsToBeThereOnBookingPage({
Expand All @@ -185,7 +184,6 @@ test.describe("Manage Booking Questions", () => {
},
email: "[email protected]",
guests: ["[email protected]", "[email protected]"],
notes: "This is an additional note",
},
});
});
Expand Down
2 changes: 0 additions & 2 deletions packages/embeds/embed-core/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ if (only === "all" || only === "ns:default") {
},
name: "John",
email: "[email protected]",
notes: "Test Meeting",
guests: ["[email protected]", "[email protected]"],
theme: "dark",
"flag.coep": "true",
Expand Down Expand Up @@ -454,7 +453,6 @@ if (only === "all" || only == "ns:floatingButton") {
"flag.coep": "true",
name: "John",
email: "[email protected]",
notes: "Test Meeting",
guests: ["[email protected]", "[email protected]"],
...(theme ? { theme } : {}),
},
Expand Down
55 changes: 50 additions & 5 deletions packages/features/bookings/Booker/Booker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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(() =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -128,6 +133,8 @@ const BookerComponent = ({

const { handleBookEvent, errors, loadingStates, expiryTime, instantVideoMeetingUrl } = bookings;

const watchedCfToken = bookingForm.watch("cfToken");

const {
isEmailVerificationModalVisible,
setEmailVerificationModalVisible,
Expand All @@ -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}
Expand Down Expand Up @@ -249,6 +273,8 @@ const BookerComponent = ({
verifyCode?.verifyCodeWithSessionNotRequired,
verifyCode?.verifyCodeWithSessionRequired,
isPlatform,
shouldRenderCaptcha,
isVerificationCodeSending,
]);

/**
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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;
};

/**
Expand All @@ -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);
Expand All @@ -71,6 +81,9 @@ export const AvailableTimeSlots = ({
showAvailableSeatsCount,
});
}
if (skipConfirmStep) {
onSubmit(time);
}
return;
};

Expand Down Expand Up @@ -135,9 +148,9 @@ export const AvailableTimeSlots = ({
showTimeFormatToggle={!isColumnView}
onTimeSelect={onTimeSelect}
slots={slots.slots}
seatsPerTimeSlot={seatsPerTimeSlot}
showAvailableSeatsCount={showAvailableSeatsCount}
event={event}
skipConfirmStep={skipConfirmStep}
{...props}
/>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -37,7 +29,7 @@ type BookEventFormProps = {
extraOptions: Record<string, string | string[]>;
isPlatform?: boolean;
isVerificationCodeSending: boolean;
renderCaptcha?: boolean;
shouldRenderCaptcha?: boolean;
};

export const BookEventForm = ({
Expand All @@ -54,7 +46,7 @@ export const BookEventForm = ({
extraOptions,
isVerificationCodeSending,
isPlatform = false,
renderCaptcha,
shouldRenderCaptcha,
}: Omit<BookEventFormProps, "event"> & {
eventQuery: {
isError: boolean;
Expand All @@ -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();

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit be1b9d1

Please sign in to comment.