refactor: booker component

This commit is contained in:
Morgan Vernay 2024-01-11 14:22:18 +02:00
parent 070ec326aa
commit 138dbd52d3
21 changed files with 986 additions and 732 deletions

View File

@ -1,34 +1,37 @@
import { LazyMotion, m, AnimatePresence } from "framer-motion";
import dynamic from "next/dynamic";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import dayjs from "@calcom/dayjs";
import { useEmbedType, useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
import { Button } from "@calcom/ui";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { VerifyCodeDialog } from "../components/VerifyCodeDialog";
import { AvailableTimeSlots } from "./components/AvailableTimeSlots";
import { BookEventForm } from "./components/BookEventForm";
import { BookEventContainer, BookEventForm } from "./components/BookEventForm";
import { BookFormAsModal } from "./components/BookEventForm/BookFormAsModal";
import { EventMeta } from "./components/EventMeta";
import { Header } from "./components/Header";
import { InstantBooking } from "./components/InstantBooking";
import { LargeCalendar } from "./components/LargeCalendar";
import { RedirectToInstantMeetingModal } from "./components/RedirectToInstantMeetingModal";
import { BookerSection } from "./components/Section";
import { Away, NotFound } from "./components/Unavailable";
import { extraDaysConfig, fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
import { useBookerLayout } from "./components/hooks/useBookerLayout";
import { useBookingForm } from "./components/hooks/useBookingForm";
import { useSlots } from "./components/hooks/useSlots";
import { useVerifyEmail } from "./components/hooks/useVerifyEmail";
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
import { useBookerStore, useInitializeBookerStore } from "./store";
import type { BookerLayout, BookerProps } from "./types";
import type { BookerProps } from "./types";
import { useEvent, useScheduleForEvent } from "./utils/event";
import { validateLayout } from "./utils/layout";
import { getQueryParam } from "./utils/query-param";
import { useBrandColors } from "./utils/use-brand-colors";
const loadFramerFeatures = () => import("./framer-features").then((res) => res.default);
@ -53,6 +56,14 @@ const BookerComponent = ({
hashedLink,
isInstantMeeting = false,
}: BookerProps) => {
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
// const seatedEventData = useBookerStore((state) => state.seatedEventData);
const [seatedEventData, setSeatedEventData] = useBookerStore(
(state) => [state.seatedEventData, state.setSeatedEventData],
shallow
);
/**
* Prioritize dateSchedule load
* Component will render but use data already fetched from here, and no duplicate requests will be made
@ -64,22 +75,45 @@ const BookerComponent = ({
month,
duration,
});
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
const event = useEvent();
const { selectedTimeslot, setSelectedTimeslot, handleRemoveSlot, handleReserveSlot } = useSlots(event);
const {
shouldShowFormInDialog,
hasDarkBackground,
extraDays,
columnViewExtraDays,
isMobile,
layout,
defaultLayout,
hideEventTypeDetails,
isEmbed,
bookerLayouts,
} = useBookerLayout(event.data);
const animationScope = useBookerResizeAnimation(layout, bookerState);
useBrandColors({
brandColor: event.data?.profile.brandColor,
darkBrandColor: event.data?.profile.darkBrandColor,
theme: event.data?.profile.theme,
});
const date = dayjs(selectedDate).format("YYYY-MM-DD");
const largeCalendarSchedule = useScheduleForEvent({
prefetchNextMonth:
layout === BookerLayouts.WEEK_VIEW &&
!!extraDays &&
dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
});
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter(
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
);
const timeslotsRef = useRef<HTMLDivElement>(null);
const StickyOnDesktop = isMobile ? "div" : StickyBox;
const rescheduleUid =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null;
const bookingUid =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null;
const event = useEvent();
const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
const isEmbed = useIsEmbed();
const embedType = useEmbedType();
// Floating Button and Element Click both are modal and thus have dark background
const hasDarkBackground = isEmbed && embedType !== "inline";
const embedUiConfig = useEmbedUiConfig();
const { t } = useLocale();
@ -89,32 +123,6 @@ const BookerComponent = ({
// In Embed we give preference to embed configuration for the layout.If that's not set, we use the App configuration for the event layout
// But if it's mobile view, there is only one layout supported which is 'mobile'
const layout = isEmbed ? (isMobile ? "mobile" : validateLayout(embedUiConfig.layout) || _layout) : _layout;
const columnViewExtraDays = useRef<number>(
isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop
);
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
const [selectedTimeslot, setSelectedTimeslot] = useBookerStore(
(state) => [state.selectedTimeslot, state.setSelectedTimeslot],
shallow
);
// const seatedEventData = useBookerStore((state) => state.seatedEventData);
const [seatedEventData, setSeatedEventData] = useBookerStore(
(state) => [state.seatedEventData, state.setSeatedEventData],
shallow
);
const date = dayjs(selectedDate).format("YYYY-MM-DD");
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter(
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
);
const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop;
const bookerLayouts = event.data?.profile?.bookerLayouts || defaultBookerLayoutSettings;
const animationScope = useBookerResizeAnimation(layout, bookerState);
const totalWeekDays = 7;
const addonDays =
nonEmptyScheduleDays.length < extraDays
@ -122,7 +130,6 @@ const BookerComponent = ({
: nonEmptyScheduleDays.length === extraDays
? totalWeekDays
: 0;
// Taking one more available slot(extraDays + 1) to calculate the no of days in between, that next and prev button need to shift.
const availableSlots = nonEmptyScheduleDays.slice(0, extraDays + 1);
if (nonEmptyScheduleDays.length !== 0)
@ -138,17 +145,6 @@ const BookerComponent = ({
const nextSlots =
Math.abs(dayjs(selectedDate).diff(availableSlots[availableSlots.length - 1], "day")) + addonDays;
// I would expect isEmbed to be not needed here as it's handled in derived variable layout, but somehow removing it breaks the views.
const defaultLayout = isEmbed
? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout
: bookerLayouts.defaultLayout;
useBrandColors({
brandColor: event.data?.profile.brandColor,
darkBrandColor: event.data?.profile.darkBrandColor,
theme: event.data?.profile.theme,
});
useInitializeBookerStore({
username,
eventSlug,
@ -164,28 +160,34 @@ const BookerComponent = ({
isInstantMeeting,
});
useEffect(() => {
if (isMobile && layout !== "mobile") {
setLayout("mobile");
} else if (!isMobile && layout === "mobile") {
setLayout(defaultLayout);
}
}, [isMobile, setLayout, layout, defaultLayout]);
const {
handleBookEvent,
bookerFormErrorRef,
key,
errors,
loadingStates,
hasInstantMeetingTokenExpired,
formEmail,
formName,
bookingForm,
beforeVerifyEmail,
} = useBookingForm({
event,
hashedLink,
});
//setting layout from query param
useEffect(() => {
const layout = getQueryParam("layout") as BookerLayouts;
if (
!isMobile &&
!isEmbed &&
validateLayout(layout) &&
bookerLayouts?.enabledLayouts?.length &&
layout !== _layout
) {
const validLayout = bookerLayouts.enabledLayouts.find((userLayout) => userLayout === layout);
validLayout && setLayout(validLayout);
}
}, [bookerLayouts, validateLayout, setLayout, _layout]);
const {
isEmailVerificationModalVisible,
setEmailVerificationModalVisible,
handleVerifyEmail,
setVerifiedEmail,
renderConfirmNotVerifyEmailButtonCond,
} = useVerifyEmail({
email: formEmail,
name: formName,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
onVerifyEmail: beforeVerifyEmail,
});
useEffect(() => {
if (event.isLoading) return setBookerState("loading");
@ -194,8 +196,6 @@ const BookerComponent = ({
return setBookerState("booking");
}, [event, selectedDate, selectedTimeslot, setBookerState]);
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
if (entity.isUnpublished) {
return <UnpublishedEntity {...entity} />;
}
@ -204,26 +204,49 @@ const BookerComponent = ({
return <NotFound />;
}
// In Embed, a Dialog doesn't look good, we disable it intentionally for the layouts that support showing Form without Dialog(i.e. no-dialog Form)
const shouldShowFormInDialogMap: Record<BookerLayout, boolean> = {
// mobile supports showing the Form without Dialog
mobile: !isEmbed,
// We don't show Dialog in month_view currently. Can be easily toggled though as it supports no-dialog Form
month_view: false,
// week_view doesn't support no-dialog Form
// When it's supported, disable it for embed
week_view: true,
// column_view doesn't support no-dialog Form
// When it's supported, disable it for embed
column_view: true,
};
const shouldShowFormInDialog = shouldShowFormInDialogMap[layout];
if (bookerState === "loading") {
return null;
}
const BookEventWizard = (
<BookEventContainer
onRemoveSelectedSlot={handleRemoveSlot}
onReserveSlot={handleReserveSlot}
event={event.data}>
<BookEventForm
key={key}
onCancel={() => {
setSelectedTimeslot(null);
if (seatedEventData.bookingUid) {
setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined });
}
}}
onSubmit={renderConfirmNotVerifyEmailButtonCond ? handleBookEvent : handleVerifyEmail}
errorRef={bookerFormErrorRef}
errors={errors}
loadingStates={loadingStates}
renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond}
bookingForm={bookingForm as unknown as UseFormReturn<FieldValues, any>}
eventQuery={event}
rescheduleUid={rescheduleUid}>
<>
<VerifyCodeDialog
isOpenDialog={isEmailVerificationModalVisible}
setIsOpenDialog={setEmailVerificationModalVisible}
email={formEmail}
onSuccess={() => {
setVerifiedEmail(formEmail);
setEmailVerificationModalVisible(false);
handleBookEvent();
}}
isUserSessionRequiredToVerify={false}
/>
<RedirectToInstantMeetingModal hasInstantMeetingTokenExpired={hasInstantMeetingTokenExpired} />
</>
</BookEventForm>
</BookEventContainer>
);
return (
<>
{event.data ? <BookingPageTagManager eventType={event.data} /> : null}
@ -303,11 +326,11 @@ const BookerComponent = ({
<BookerSection
area="meta"
className="max-w-screen flex w-full flex-col md:w-[var(--booker-meta-width)]">
<EventMeta />
<EventMeta event={event.data} isLoading={event.isLoading} />
{layout !== BookerLayouts.MONTH_VIEW &&
!(layout === "mobile" && bookerState === "booking") && (
<div className="mt-auto px-5 py-3 ">
<DatePicker />
<DatePicker event={event} />
</div>
)}
</BookerSection>
@ -319,15 +342,7 @@ const BookerComponent = ({
className="border-subtle sticky top-0 ml-[-1px] h-full p-6 md:w-[var(--booker-main-width)] md:border-l"
{...fadeInLeft}
visible={bookerState === "booking" && !shouldShowFormInDialog}>
<BookEventForm
onCancel={() => {
setSelectedTimeslot(null);
if (seatedEventData.bookingUid) {
setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined });
}
}}
hashedLink={hashedLink}
/>
{BookEventWizard}
</BookerSection>
<BookerSection
@ -337,7 +352,7 @@ const BookerComponent = ({
{...fadeInLeft}
initial="visible"
className="md:border-subtle ml-[-1px] h-full flex-shrink px-5 py-3 md:border-l lg:w-[var(--booker-main-width)]">
<DatePicker />
<DatePicker event={event} />
</BookerSection>
<BookerSection
@ -346,7 +361,7 @@ const BookerComponent = ({
visible={layout === BookerLayouts.WEEK_VIEW}
className="border-subtle sticky top-0 ml-[-1px] h-full md:border-l"
{...fadeInLeft}>
<LargeCalendar extraDays={extraDays} />
<LargeCalendar extraDays={extraDays} schedule={largeCalendarSchedule} />
</BookerSection>
<BookerSection
@ -371,6 +386,7 @@ const BookerComponent = ({
monthCount={monthCount}
seatsPerTimeSlot={event.data?.seatsPerTimeSlot}
showAvailableSeatsCount={event.data?.seatsShowAvailabilityCount}
event={event}
/>
</BookerSection>
</AnimatePresence>
@ -388,9 +404,10 @@ const BookerComponent = ({
</div>
<BookFormAsModal
visible={bookerState === "booking" && shouldShowFormInDialog}
onCancel={() => setSelectedTimeslot(null)}
/>
visible={bookerState === "booking" && shouldShowFormInDialog}>
{BookEventWizard}
</BookFormAsModal>
</>
);
};
@ -404,54 +421,3 @@ export const Booker = (props: BookerProps) => {
</LazyMotion>
);
};
export const InstantBooking = () => {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
return (
<div className=" bg-default border-subtle mx-2 block items-center gap-3 rounded-xl border p-[6px] text-sm shadow-sm delay-1000 sm:flex">
<div className="flex items-center gap-3 ps-1">
{/* TODO: max. show 4 people here */}
<div className="relative">
{/* <AvatarGroup
size="sm"
className="relative"
items={[
{
image: "https://cal.com/stakeholder/peer.jpg",
alt: "Peer",
title: "Peer Richelsen",
},
{
image: "https://cal.com/stakeholder/bailey.jpg",
alt: "Bailey",
title: "Bailey Pumfleet",
},
{
image: "https://cal.com/stakeholder/alex-van-andel.jpg",
alt: "Alex",
title: "Alex Van Andel",
},
]}
/> */}
<div className="border-muted absolute -bottom-0.5 -right-1 h-2 w-2 rounded-full border bg-green-500" />
</div>
<div>{t("dont_want_to_wait")}</div>
</div>
<div className="mt-2 sm:mt-0">
<Button
color="primary"
onClick={() => {
const newPath = `${pathname}?isInstantMeeting=true`;
router.push(newPath);
}}
size="sm"
className="w-full justify-center rounded-lg sm:w-auto">
{t("connect_now")}
</Button>
</div>
</div>
);
};

View File

@ -8,11 +8,11 @@ import { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-sc
import { classNames } from "@calcom/lib";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc";
import { AvailableTimesHeader } from "../../components/AvailableTimesHeader";
import { useBookerStore } from "../store";
import { useEvent, useScheduleForEvent } from "../utils/event";
import type { useEventReturnType } from "../utils/event";
import { useScheduleForEvent } from "../utils/event";
type AvailableTimeSlotsProps = {
extraDays?: number;
@ -21,6 +21,7 @@ type AvailableTimeSlotsProps = {
monthCount: number | undefined;
seatsPerTimeSlot?: number | null;
showAvailableSeatsCount?: boolean | null;
event: useEventReturnType;
};
/**
@ -37,14 +38,13 @@ export const AvailableTimeSlots = ({
showAvailableSeatsCount,
prefetchNextMonth,
monthCount,
event,
}: AvailableTimeSlotsProps) => {
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
const isMobile = useMediaQuery("(max-width: 768px)");
const selectedDate = useBookerStore((state) => state.selectedDate);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
const setSeatedEventData = useBookerStore((state) => state.setSeatedEventData);
const isEmbed = useIsEmbed();
const event = useEvent();
const date = selectedDate || dayjs().format("YYYY-MM-DD");
const [layout] = useBookerStore((state) => [state.layout]);
const isColumnView = layout === BookerLayouts.COLUMN_VIEW;

View File

@ -1,316 +1,91 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { UseMutationResult } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import type {
IUseBookingFormErrors,
IUseBookingFormLoadingStates,
} from "bookings/Booker/components/hooks/useBookingForm";
import type { TFunction } from "next-i18next";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import type { FieldError } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import type { EventLocationType } from "@calcom/app-store/locations";
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import dayjs from "@calcom/dayjs";
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { VerifyCodeDialog } from "@calcom/features/bookings/components/VerifyCodeDialog";
import {
createBooking,
createRecurringBooking,
mapBookingToMutationInput,
mapRecurringBookingToMutationInput,
useTimePreferences,
createInstantBooking,
} from "@calcom/features/bookings/lib";
import getBookingResponsesSchema, {
getBookingResponsesPartialSchema,
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
import { getFullName } from "@calcom/features/form-builder/utils";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc";
import { Dialog, DialogContent } from "@calcom/ui";
import { Alert, Button, EmptyScreen, Form, showToast } from "@calcom/ui";
import { Alert, Button, EmptyScreen, Form } from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { useBookerStore } from "../../store";
import { useSlotReservationId } from "../../useSlotReservationId";
import { useEvent } from "../../utils/event";
import type { useEventReturnType } from "../../utils/event";
import { BookingFields } from "./BookingFields";
import { FormSkeleton } from "./Skeleton";
type BookEventFormProps = {
onCancel?: () => void;
hashedLink?: string | null;
onSubmit: () => void;
errorRef: React.RefObject<HTMLDivElement>;
errors: IUseBookingFormErrors;
loadingStates: IUseBookingFormLoadingStates;
children?: React.ReactNode;
bookingForm: UseFormReturn<FieldValues, any>;
renderConfirmNotVerifyEmailButtonCond: boolean;
};
type DefaultValues = Record<string, unknown>;
type BookEventContainerProps = {
onReserveSlot: () => void;
onRemoveSelectedSlot: () => void;
event: useEventReturnType["data"];
children?: React.ReactNode;
};
export const BookEventForm = ({ onCancel, hashedLink }: BookEventFormProps) => {
const [slotReservationId, setSlotReservationId] = useSlotReservationId();
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation({
trpc: {
context: {
skipBatch: true,
},
},
onSuccess: (data) => {
setSlotReservationId(data.uid);
},
});
const removeSelectedSlot = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation({
trpc: { context: { skipBatch: true } },
});
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
const bookingData = useBookerStore((state) => state.bookingData);
const duration = useBookerStore((state) => state.selectedDuration);
export const BookEventContainer = ({
onReserveSlot,
onRemoveSelectedSlot,
event,
children,
}: BookEventContainerProps) => {
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const isRescheduling = !!rescheduleUid && !!bookingData;
const eventQuery = useEvent();
const eventType = eventQuery.data;
const reserveSlot = () => {
if (eventType?.id && timeslot && (duration || eventType?.length)) {
reserveSlotMutation.mutate({
slotUtcStartDate: dayjs(timeslot).utc().format(),
eventTypeId: eventType?.id,
slotUtcEndDate: dayjs(timeslot)
.utc()
.add(duration || eventType?.length, "minutes")
.format(),
});
}
};
useEffect(() => {
reserveSlot();
onReserveSlot();
const interval = setInterval(() => {
reserveSlot();
onReserveSlot();
}, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000);
return () => {
if (eventType) {
removeSelectedSlot.mutate({ uid: slotReservationId });
}
onRemoveSelectedSlot();
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventType?.id, timeslot]);
}, [event?.id, timeslot]);
const { initialValues, key } = useInitialFormValues({
eventType,
rescheduleUid,
isRescheduling,
});
return (
<BookEventFormChild
// initialValues would be null initially as the async schema parsing is happening. Let's show the form in first render without any prefill values
// But ensure that when initialValues is available, the form is reset and rerendered with the prefill values
key={key}
onCancel={onCancel}
initialValues={initialValues}
isRescheduling={isRescheduling}
eventQuery={eventQuery}
rescheduleUid={rescheduleUid}
hashedLink={hashedLink}
isInstantMeeting={isInstantMeeting}
/>
);
return <div className="flex h-full flex-col">{children}</div>;
};
export const BookEventFormChild = ({
export const BookEventForm = ({
onCancel,
initialValues,
isRescheduling,
eventQuery,
rescheduleUid,
hashedLink,
isInstantMeeting,
}: BookEventFormProps & {
initialValues: DefaultValues;
isRescheduling: boolean;
eventQuery: ReturnType<typeof useEvent>;
onSubmit,
errorRef,
errors,
loadingStates,
renderConfirmNotVerifyEmailButtonCond,
bookingForm,
children,
}: Omit<BookEventFormProps, "event"> & {
eventQuery: useEventReturnType;
rescheduleUid: string | null;
hashedLink?: string | null;
isInstantMeeting?: boolean;
}) => {
const eventType = eventQuery.data;
const bookingFormSchema = z
.object({
responses: eventQuery?.data
? getBookingResponsesSchema({
eventType: eventQuery?.data,
view: rescheduleUid ? "reschedule" : "booking",
})
: // Fallback until event is loaded.
z.object({}),
})
.passthrough();
const searchParams = useSearchParams();
const routerQuery = useRouterQuery();
const setFormValues = useBookerStore((state) => state.setFormValues);
const seatedEventData = useBookerStore((state) => state.seatedEventData);
const verifiedEmail = useBookerStore((state) => state.verifiedEmail);
const setVerifiedEmail = useBookerStore((state) => state.setVerifiedEmail);
const bookingSuccessRedirect = useBookingSuccessRedirect();
const [responseVercelIdHeader, setResponseVercelIdHeader] = useState<string | null>(null);
const router = useRouter();
const { t, i18n } = useLocale();
const { timezone } = useTimePreferences();
const errorRef = useRef<HTMLDivElement>(null);
const bookingData = useBookerStore((state) => state.bookingData);
const eventSlug = useBookerStore((state) => state.eventSlug);
const duration = useBookerStore((state) => state.selectedDuration);
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const username = useBookerStore((state) => state.username);
const [expiryTime, setExpiryTime] = useState<Date | undefined>();
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
type BookingFormValues = {
locationType?: EventLocationType["type"];
responses: z.infer<typeof bookingFormSchema>["responses"] | null;
// Key is not really part of form values, but only used to have a key
// to set generic error messages on. Needed until RHF has implemented root error keys.
globalError: undefined;
};
const bookingForm = useForm<BookingFormValues>({
defaultValues: initialValues,
resolver: zodResolver(
// Since this isn't set to strict we only validate the fields in the schema
bookingFormSchema,
{},
{
// bookingFormSchema is an async schema, so inform RHF to do async validation.
mode: "async",
}
),
});
const createBookingMutation = useMutation(createBooking, {
onSuccess: (responseData) => {
const { uid, paymentUid } = responseData;
const fullName = getFullName(bookingForm.getValues("responses.name"));
if (paymentUid) {
router.push(
createPaymentLink({
paymentUid,
date: timeslot,
name: fullName,
email: bookingForm.getValues("responses.email"),
absolute: false,
})
);
return;
}
if (!uid) {
console.error("No uid returned from createBookingMutation");
return;
}
const query = {
isSuccessBookingPage: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventSlug,
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
formerTime:
isRescheduling && bookingData?.startTime ? dayjs(bookingData.startTime).toString() : undefined,
};
bookingSuccessRedirect({
successRedirectUrl: eventType?.successRedirectUrl || "",
query,
booking: responseData,
});
},
onError: (err, _, ctx) => {
// TODO:
// const vercelId = ctx?.meta?.headers?.get("x-vercel-id");
// if (vercelId) {
// setResponseVercelIdHeader(vercelId);
// }
errorRef && errorRef.current?.scrollIntoView({ behavior: "smooth" });
},
});
const createInstantBookingMutation = useMutation(createInstantBooking, {
onSuccess: (responseData) => {
updateQueryParam("bookingId", responseData.bookingId);
setExpiryTime(responseData.expires);
},
onError: (err, _, ctx) => {
console.error("Error creating instant booking", err);
errorRef && errorRef.current?.scrollIntoView({ behavior: "smooth" });
},
});
const createRecurringBookingMutation = useMutation(createRecurringBooking, {
onSuccess: async (responseData) => {
const booking = responseData[0] || {};
const { uid } = booking;
if (!uid) {
console.error("No uid returned from createRecurringBookingMutation");
return;
}
const query = {
isSuccessBookingPage: true,
allRemainingBookings: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventSlug,
formerTime:
isRescheduling && bookingData?.startTime ? dayjs(bookingData.startTime).toString() : undefined,
};
bookingSuccessRedirect({
successRedirectUrl: eventType?.successRedirectUrl || "",
query,
booking,
});
},
});
const [isEmailVerificationModalVisible, setEmailVerificationModalVisible] = useState(false);
const email = bookingForm.watch("responses.email");
const sendEmailVerificationByCodeMutation = trpc.viewer.auth.sendVerifyEmailCode.useMutation({
onSuccess() {
showToast(t("email_sent"), "success");
},
onError() {
showToast(t("email_not_sent"), "error");
},
});
const verifyEmail = () => {
bookingForm.clearErrors();
// It shouldn't be possible that this method is fired without having event data,
// but since in theory (looking at the types) it is possible, we still handle that case.
if (!eventQuery?.data) {
bookingForm.setError("globalError", { message: t("error_booking_event") });
return;
}
const name = bookingForm.getValues("responses.name");
sendEmailVerificationByCodeMutation.mutate({
email,
username: typeof name === "string" ? name : name.firstName,
});
setEmailVerificationModalVisible(true);
};
const [responseVercelIdHeader, setResponseVercelIdHeader] = useState<string | null>(null);
const { t } = useLocale();
if (eventQuery.isError) return <Alert severity="warning" message={t("error_booking_event")} />;
if (eventQuery.isLoading || !eventQuery.data) return <FormSkeleton />;
@ -325,66 +100,11 @@ export const BookEventFormChild = ({
/>
);
const bookEvent = (values: BookingFormValues) => {
// It shouldn't be possible that this method is fired without having eventQuery data,
// but since in theory (looking at the types) it is possible, we still handle that case.
if (!eventQuery?.data) {
bookingForm.setError("globalError", { message: t("error_booking_event") });
return;
}
// Ensures that duration is an allowed value, if not it defaults to the
// default eventQuery duration.
const validDuration = eventQuery.data.isDynamic
? duration || eventQuery.data.length
: duration && eventQuery.data.metadata?.multipleDuration?.includes(duration)
? duration
: eventQuery.data.length;
const bookingInput = {
values,
duration: validDuration,
event: eventQuery.data,
date: timeslot,
timeZone: timezone,
language: i18n.language,
rescheduleUid: rescheduleUid || undefined,
bookingUid: (bookingData && bookingData.uid) || seatedEventData?.bookingUid || undefined,
username: username || "",
metadata: Object.keys(routerQuery)
.filter((key) => key.startsWith("metadata"))
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: searchParams?.get(key),
}),
{}
),
hashedLink,
};
if (isInstantMeeting) {
createInstantBookingMutation.mutate(mapBookingToMutationInput(bookingInput));
} else if (eventQuery.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
createRecurringBookingMutation.mutate(
mapRecurringBookingToMutationInput(bookingInput, recurringEventCount)
);
} else {
createBookingMutation.mutate(mapBookingToMutationInput(bookingInput));
}
// Clears form values stored in store, so old values won't stick around.
setFormValues({});
bookingForm.clearErrors();
};
if (!eventType) {
console.warn("No event type found for event", routerQuery);
return <Alert severity="warning" message={t("error_booking_event")} />;
}
const renderConfirmNotVerifyEmailButtonCond =
!eventType?.requiresBookerEmailVerification || (email && verifiedEmail && verifiedEmail === email);
return (
<div className="flex h-full flex-col">
<Form
@ -397,7 +117,7 @@ export const BookEventFormChild = ({
setFormValues(values);
}}
form={bookingForm}
handleSubmit={renderConfirmNotVerifyEmailButtonCond ? bookEvent : verifyEmail}
handleSubmit={onSubmit}
noValidate>
<BookingFields
isDynamicGroupBooking={!!(username && username.indexOf("+") > -1)}
@ -406,30 +126,20 @@ export const BookEventFormChild = ({
rescheduleUid={rescheduleUid || undefined}
bookingData={bookingData}
/>
{(createBookingMutation.isError ||
createRecurringBookingMutation.isError ||
createInstantBookingMutation.isError ||
bookingForm.formState.errors["globalError"]) && (
{errors.hasFormErrors && (
<div data-testid="booking-fail">
<Alert
ref={errorRef}
className="my-2"
severity="info"
title={rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
message={getError(
bookingForm.formState.errors["globalError"],
createBookingMutation,
createRecurringBookingMutation,
createInstantBookingMutation,
t,
responseVercelIdHeader
)}
message={getError(errors.formErrors, errors.dataErrors, t, responseVercelIdHeader)}
/>
</div>
)}
<div className="modalsticky mt-auto flex justify-end space-x-2 rtl:space-x-reverse">
{isInstantMeeting ? (
<Button type="submit" color="primary" loading={createInstantBookingMutation.isLoading}>
<Button type="submit" color="primary" loading={loadingStates.creatingInstantBooking}>
{t("confirm")}
</Button>
) : (
@ -442,14 +152,7 @@ export const BookEventFormChild = ({
<Button
type="submit"
color="primary"
loading={
bookingForm.formState.isSubmitting ||
createBookingMutation.isLoading ||
createRecurringBookingMutation.isLoading ||
// A redirect is triggered on mutation success, so keep the button disabled as this is happening.
createBookingMutation.isSuccess ||
createRecurringBookingMutation.isSuccess
}
loading={loadingStates.creatingBooking || loadingStates.creatingRecurringBooking}
data-testid={
rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"
}>
@ -463,102 +166,24 @@ export const BookEventFormChild = ({
)}
</div>
</Form>
<VerifyCodeDialog
isOpenDialog={isEmailVerificationModalVisible}
setIsOpenDialog={setEmailVerificationModalVisible}
email={email}
onSuccess={() => {
setVerifiedEmail(email);
setEmailVerificationModalVisible(false);
bookEvent(bookingForm.getValues());
}}
isUserSessionRequiredToVerify={false}
/>
<RedirectToInstantMeetingModal expiryTime={expiryTime} />
{children}
</div>
);
};
const RedirectToInstantMeetingModal = ({ expiryTime }: { expiryTime?: Date }) => {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const bookingId = parseInt(getQueryParam("bookingId") || "0");
const hasInstantMeetingTokenExpired = expiryTime && new Date(expiryTime) < new Date();
const instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery(
{
bookingId: bookingId,
},
{
enabled: !!bookingId && !hasInstantMeetingTokenExpired,
refetchInterval: 2000,
onSuccess: (data) => {
try {
showToast(t("something_went_wrong_on_our_end"), "error");
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
data.booking?.metadata || {}
)?.videoCallUrl;
if (locationVideoCallUrl) {
router.push(locationVideoCallUrl);
} else {
showToast(t("something_went_wrong_on_our_end"), "error");
}
} catch (err) {
showToast(t("something_went_wrong_on_our_end"), "error");
}
},
}
);
return (
<Dialog open={!!bookingId}>
<DialogContent enableOverflow className="py-8">
<div>
{hasInstantMeetingTokenExpired ? (
<div>
<p className="font-medium">{t("please_book_a_time_sometime_later")}</p>
<Button
className="mt-4"
onClick={() => {
// Prevent null on app directory
if (pathname) window.location.href = pathname;
}}
color="primary">
{t("go_back")}
</Button>
</div>
) : (
<div>
<p className="font-medium">{t("connecting_you_to_someone")}</p>
<p className="font-medium">{t("please_do_not_close_this_tab")}</p>
<Spinner className="relative mt-8" />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
const getError = (
globalError: FieldError | undefined,
// It feels like an implementation detail to reimplement the types of useMutation here.
// Since they don't matter for this function, I'd rather disable them then giving you
// the cognitive overload of thinking to update them here when anything changes.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bookingMutation: UseMutationResult<any, any, any, any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
recurringBookingMutation: UseMutationResult<any, any, any, any>,
createInstantBookingMutation: UseMutationResult<any, any, any, any>,
dataError: any,
t: TFunction,
responseVercelIdHeader: string | null
) => {
if (globalError) return globalError.message;
const error = bookingMutation.error || recurringBookingMutation.error || createInstantBookingMutation.error;
const error = dataError;
return error.message ? (
<>
@ -568,117 +193,3 @@ const getError = (
<>{t("can_you_try_again")}</>
);
};
function useInitialFormValues({
eventType,
rescheduleUid,
isRescheduling,
}: {
eventType: ReturnType<typeof useEvent>["data"];
rescheduleUid: string | null;
isRescheduling: boolean;
}) {
const [initialValues, setDefaultValues] = useState<DefaultValues>({});
const bookingData = useBookerStore((state) => state.bookingData);
const formValues = useBookerStore((state) => state.formValues);
const searchParams = useSearchParams();
const routerQuery = useRouterQuery();
const session = useSession();
useEffect(() => {
(async function () {
if (Object.keys(formValues).length) {
setDefaultValues(formValues);
return;
}
if (!eventType?.bookingFields) {
return {};
}
const querySchema = getBookingResponsesPartialSchema({
eventType: {
bookingFields: eventType.bookingFields,
},
view: rescheduleUid ? "reschedule" : "booking",
});
// Routing Forms don't support Split full name(because no Form Builder in there), so user needs to create two fields in there themselves. If they name these fields, `firstName` and `lastName`, we can prefill the Booking Form with them
// Once we support formBuilder in Routing Forms, we should be able to forward JSON form of name field value to Booking Form and prefill it there without having these two query params separately.
const firstNameQueryParam = searchParams?.get("firstName");
const lastNameQueryParam = searchParams?.get("lastName");
const parsedQuery = await querySchema.parseAsync({
...routerQuery,
name:
searchParams?.get("name") ||
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
// `guest` because we need to support legacy URL with `guest` query param support
// `guests` because the `name` of the corresponding bookingField is `guests`
guests: searchParams?.getAll("guests") || searchParams?.getAll("guest"),
});
const defaultUserValues = {
email:
rescheduleUid && bookingData && bookingData.attendees.length > 0
? bookingData?.attendees[0].email
: !!parsedQuery["email"]
? parsedQuery["email"]
: session.data?.user?.email ?? "",
name:
rescheduleUid && bookingData && bookingData.attendees.length > 0
? bookingData?.attendees[0].name
: !!parsedQuery["name"]
? parsedQuery["name"]
: session.data?.user?.name ?? session.data?.user?.username ?? "",
};
if (!isRescheduling) {
const defaults = {
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
return {
...responses,
[field.name]: parsedQuery[field.name] || undefined,
};
}, {});
defaults.responses = {
...responses,
name: defaultUserValues.name,
email: defaultUserValues.email,
};
setDefaultValues(defaults);
}
if (!rescheduleUid && !bookingData) {
return {};
}
// We should allow current session user as default values for booking form
const defaults = {
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
return {
...responses,
[field.name]: bookingData?.responses[field.name],
};
}, {});
defaults.responses = {
...responses,
name: defaultUserValues.name,
email: defaultUserValues.email,
};
setDefaultValues(defaults);
})();
}, [eventType?.bookingFields, formValues, isRescheduling, bookingData, rescheduleUid]);
// When initialValues is available(after doing async schema parsing) or session is available(so that we can prefill logged-in user email and name), we need to reset the form with the initialValues
const key = `${Object.keys(initialValues).length}_${session ? 1 : 0}`;
return { initialValues, key };
}

View File

@ -1,4 +1,5 @@
import { Calendar, Clock } from "lucide-react";
import type { ReactNode } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -7,9 +8,8 @@ import { Badge, Dialog, DialogContent } from "@calcom/ui";
import { useTimePreferences } from "../../../lib";
import { useBookerStore } from "../../store";
import { useEvent } from "../../utils/event";
import { BookEventForm } from "./BookEventForm";
const BookEventFormWrapper = ({ onCancel }: { onCancel: () => void }) => {
const BookEventFormWrapper = ({ children }: { onCancel: () => void; children: ReactNode }) => {
const { t } = useLocale();
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
const selectedDuration = useBookerStore((state) => state.selectedDuration);
@ -31,19 +31,27 @@ const BookEventFormWrapper = ({ onCancel }: { onCancel: () => void }) => {
</Badge>
)}
</div>
<BookEventForm onCancel={onCancel} />
{children}
</>
);
};
export const BookFormAsModal = ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) => {
export const BookFormAsModal = ({
visible,
onCancel,
children,
}: {
visible: boolean;
onCancel: () => void;
children: ReactNode;
}) => {
return (
<Dialog open={visible} onOpenChange={onCancel}>
<DialogContent
type={undefined}
enableOverflow
className="[&_.modalsticky]:border-t-subtle [&_.modalsticky]:bg-default max-h-[80vh] pb-0 [&_.modalsticky]:sticky [&_.modalsticky]:bottom-0 [&_.modalsticky]:left-0 [&_.modalsticky]:right-0 [&_.modalsticky]:-mx-8 [&_.modalsticky]:border-t [&_.modalsticky]:px-8 [&_.modalsticky]:py-4">
<BookEventFormWrapper onCancel={onCancel} />
<BookEventFormWrapper onCancel={onCancel}>{children}</BookEventFormWrapper>
</DialogContent>
</Dialog>
);

View File

@ -1 +1 @@
export { BookEventForm } from "./BookEventForm";
export { BookEventContainer, BookEventForm } from "./BookEventForm";

View File

@ -8,16 +8,16 @@ import { weekdayToWeekIndex } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useBookerStore } from "../store";
import { useEvent, useScheduleForEvent } from "../utils/event";
import type { useEventReturnType } from "../utils/event";
import { useScheduleForEvent } from "../utils/event";
export const DatePicker = () => {
export const DatePicker = ({ event }: { event: useEventReturnType }) => {
const { i18n } = useLocale();
const [month, selectedDate] = useBookerStore((state) => [state.month, state.selectedDate], shallow);
const [setSelectedDate, setMonth] = useBookerStore(
(state) => [state.setSelectedDate, state.setMonth],
shallow
);
const event = useEvent();
const schedule = useScheduleForEvent();
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots);

View File

@ -13,7 +13,7 @@ import { Calendar, Globe, User } from "@calcom/ui/components/icon";
import { fadeInUp } from "../config";
import { useBookerStore } from "../store";
import { FromToTime } from "../utils/dates";
import { useEvent } from "../utils/event";
import type { useEventReturnType } from "../utils/event";
const TimezoneSelect = dynamic(
() => import("@calcom/ui/components/form/timezone-select/TimezoneSelect").then((mod) => mod.TimezoneSelect),
@ -22,10 +22,15 @@ const TimezoneSelect = dynamic(
}
);
export const EventMeta = () => {
export const EventMeta = ({
event,
isLoading,
}: {
event: useEventReturnType["data"];
isLoading: useEventReturnType["isLoading"];
}) => {
const { setTimezone, timeFormat, timezone } = useTimePreferences();
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration);
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
const bookerState = useBookerStore((state) => state.state);
const bookingData = useBookerStore((state) => state.bookingData);
@ -35,7 +40,7 @@ export const EventMeta = () => {
shallow
);
const { i18n, t } = useLocale();
const { data: event, isLoading } = useEvent();
const embedUiConfig = useEmbedUiConfig();
const isEmbed = useIsEmbed();
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;

View File

@ -22,6 +22,7 @@ export function Header({
nextSlots,
username,
eventSlug,
ownerName,
}: {
extraDays: number;
isMobile: boolean;
@ -29,6 +30,7 @@ export function Header({
nextSlots: number;
username: string;
eventSlug: string;
ownerName?: string;
}) {
const { t, i18n } = useLocale();
const session = useSession();

View File

@ -0,0 +1,55 @@
import { usePathname, useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
export const InstantBooking = () => {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
return (
<div className=" bg-default border-subtle mx-2 block items-center gap-3 rounded-xl border p-[6px] text-sm shadow-sm delay-1000 sm:flex">
<div className="flex items-center gap-3 ps-1">
{/* TODO: max. show 4 people here */}
<div className="relative">
{/* <AvatarGroup
size="sm"
className="relative"
items={[
{
image: "https://cal.com/stakeholder/peer.jpg",
alt: "Peer",
title: "Peer Richelsen",
},
{
image: "https://cal.com/stakeholder/bailey.jpg",
alt: "Bailey",
title: "Bailey Pumfleet",
},
{
image: "https://cal.com/stakeholder/alex-van-andel.jpg",
alt: "Alex",
title: "Alex Van Andel",
},
]}
/> */}
<div className="border-muted absolute -bottom-0.5 -right-1 h-2 w-2 rounded-full border bg-green-500" />
</div>
<div>{t("dont_want_to_wait")}</div>
</div>
<div className="mt-2 sm:mt-0">
<Button
color="primary"
onClick={() => {
const newPath = `${pathname}?isInstantMeeting=true`;
router.push(newPath);
}}
size="sm"
className="w-full justify-center rounded-lg sm:w-auto">
{t("connect_now")}
</Button>
</div>
</div>
);
};

View File

@ -7,19 +7,22 @@ import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/week
import { localStorage } from "@calcom/lib/webstorage";
import { useBookerStore } from "../store";
import { useEvent, useScheduleForEvent } from "../utils/event";
import type { useScheduleForEventReturnType } from "../utils/event";
import { useEvent } from "../utils/event";
import { getQueryParam } from "../utils/query-param";
import { useOverlayCalendarStore } from "./OverlayCalendar/store";
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
export const LargeCalendar = ({
extraDays,
schedule,
}: {
extraDays: number;
schedule: useScheduleForEventReturnType;
}) => {
const selectedDate = useBookerStore((state) => state.selectedDate);
const date = selectedDate || dayjs().format("YYYY-MM-DD");
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
const selectedEventDuration = useBookerStore((state) => state.selectedDuration);
const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates);
const schedule = useScheduleForEvent({
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
});
const displayOverlay =
getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault");

View File

@ -0,0 +1,46 @@
import { usePathname } from "next/navigation";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Dialog, DialogContent } from "@calcom/ui";
import { Button } from "@calcom/ui";
export const RedirectToInstantMeetingModal = ({
hasInstantMeetingTokenExpired,
}: {
hasInstantMeetingTokenExpired: boolean;
}) => {
const { t } = useLocale();
const pathname = usePathname();
const bookingId = parseInt(getQueryParam("bookingId") || "0");
return (
<Dialog open={!!bookingId}>
<DialogContent enableOverflow className="py-8">
<div>
{hasInstantMeetingTokenExpired ? (
<div>
<p className="font-medium">{t("please_book_a_time_sometime_later")}</p>
<Button
className="mt-4"
onClick={() => {
// Prevent null on app directory
if (pathname) window.location.href = pathname;
}}
color="primary">
{t("go_back")}
</Button>
</div>
) : (
<div>
<p className="font-medium">{t("connecting_you_to_someone")}</p>
<p className="font-medium">{t("please_do_not_close_this_tab")}</p>
<Spinner className="relative mt-8" />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,88 @@
import type { useEventReturnType } from "bookings/Booker/utils/event";
import { useEffect, useRef } from "react";
import { shallow } from "zustand/shallow";
import { useEmbedType, useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
import { defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
import { extraDaysConfig } from "../../config";
import { useBookerStore } from "../../store";
import type { BookerLayout } from "../../types";
import { validateLayout } from "../../utils/layout";
import { getQueryParam } from "../../utils/query-param";
export const useBookerLayout = (event: useEventReturnType["data"]) => {
const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
const isEmbed = useIsEmbed();
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
const embedUiConfig = useEmbedUiConfig();
const layout = isEmbed ? (isMobile ? "mobile" : validateLayout(embedUiConfig.layout) || _layout) : _layout;
const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop;
const embedType = useEmbedType();
// Floating Button and Element Click both are modal and thus have dark background
const hasDarkBackground = isEmbed && embedType !== "inline";
const columnViewExtraDays = useRef<number>(
isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop
);
const bookerLayouts = event?.profile?.bookerLayouts || defaultBookerLayoutSettings;
const defaultLayout = isEmbed
? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout
: bookerLayouts.defaultLayout;
useEffect(() => {
if (isMobile && layout !== "mobile") {
setLayout("mobile");
} else if (!isMobile && layout === "mobile") {
setLayout(defaultLayout);
}
}, [isMobile, setLayout, layout, defaultLayout]);
//setting layout from query param
useEffect(() => {
const layout = getQueryParam("layout") as BookerLayouts;
if (
!isMobile &&
!isEmbed &&
validateLayout(layout) &&
bookerLayouts?.enabledLayouts?.length &&
layout !== _layout
) {
const validLayout = bookerLayouts.enabledLayouts.find((userLayout) => userLayout === layout);
validLayout && setLayout(validLayout);
}
}, [bookerLayouts, validateLayout, setLayout, _layout]);
// In Embed, a Dialog doesn't look good, we disable it intentionally for the layouts that support showing Form without Dialog(i.e. no-dialog Form)
const shouldShowFormInDialogMap: Record<BookerLayout, boolean> = {
// mobile supports showing the Form without Dialog
mobile: !isEmbed,
// We don't show Dialog in month_view currently. Can be easily toggled though as it supports no-dialog Form
month_view: false,
// week_view doesn't support no-dialog Form
// When it's supported, disable it for embed
week_view: true,
// column_view doesn't support no-dialog Form
// When it's supported, disable it for embed
column_view: true,
};
const shouldShowFormInDialog = shouldShowFormInDialogMap[layout];
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
return {
shouldShowFormInDialog,
hasDarkBackground,
extraDays,
columnViewExtraDays,
isMobile,
isEmbed,
isTablet,
layout,
defaultLayout,
hideEventTypeDetails,
bookerLayouts,
};
};

View File

@ -0,0 +1,330 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import type { FieldError } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { EventLocationType } from "@calcom/app-store/locations";
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import dayjs from "@calcom/dayjs";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import type { useEventReturnType } from "@calcom/features/bookings/Booker/utils/event";
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import {
createBooking,
createRecurringBooking,
mapBookingToMutationInput,
mapRecurringBookingToMutationInput,
createInstantBooking,
useTimePreferences,
} from "@calcom/features/bookings/lib";
import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getFullName } from "@calcom/features/form-builder/utils";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc";
import { showToast } from "@calcom/ui";
import { useInitialFormValues } from "./useInitialFormValues";
export interface IUseBookingForm {
event: useEventReturnType;
hashedLink?: string | null;
}
export interface IUseBookingFormErrors {
hasFormErrors: boolean;
formErrors: FieldError | undefined;
dataErrors: unknown;
}
export interface IUseBookingFormLoadingStates {
creatingBooking: boolean;
creatingRecurringBooking: boolean;
creatingInstantBooking: boolean;
}
export const useBookingForm = ({ event, hashedLink }: IUseBookingForm) => {
const router = useRouter();
const eventSlug = useBookerStore((state) => state.eventSlug);
const setFormValues = useBookerStore((state) => state.setFormValues);
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
const bookingData = useBookerStore((state) => state.bookingData);
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const seatedEventData = useBookerStore((state) => state.seatedEventData);
const { t, i18n } = useLocale();
const bookingSuccessRedirect = useBookingSuccessRedirect();
const bookerFormErrorRef = useRef<HTMLDivElement>(null);
const [expiryTime, setExpiryTime] = useState<Date | undefined>();
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const duration = useBookerStore((state) => state.selectedDuration);
const { timezone } = useTimePreferences();
const username = useBookerStore((state) => state.username);
const routerQuery = useRouterQuery();
const searchParams = useSearchParams();
const verifiedEmail = useBookerStore((state) => state.verifiedEmail);
const bookingFormSchema = z
.object({
responses: event?.data
? getBookingResponsesSchema({
eventType: event?.data,
view: rescheduleUid ? "reschedule" : "booking",
})
: // Fallback until event is loaded.
z.object({}),
})
.passthrough();
type BookingFormValues = {
locationType?: EventLocationType["type"];
responses: z.infer<typeof bookingFormSchema>["responses"] | null;
// Key is not really part of form values, but only used to have a key
// to set generic error messages on. Needed until RHF has implemented root error keys.
globalError: undefined;
};
const isRescheduling = !!rescheduleUid && !!bookingData;
const { initialValues, key } = useInitialFormValues({
eventType: event.data,
rescheduleUid,
isRescheduling,
});
const bookingForm = useForm<BookingFormValues>({
defaultValues: initialValues,
resolver: zodResolver(
// Since this isn't set to strict we only validate the fields in the schema
bookingFormSchema,
{},
{
// bookingFormSchema is an async schema, so inform RHF to do async validation.
mode: "async",
}
),
});
const email = bookingForm.watch("responses.email");
const name = bookingForm.watch("responses.name");
const bookingId = parseInt(getQueryParam("bookingId") || "0");
const hasInstantMeetingTokenExpired = expiryTime && new Date(expiryTime) < new Date();
const _instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery(
{
bookingId: bookingId,
},
{
enabled: !!bookingId && !hasInstantMeetingTokenExpired,
refetchInterval: 2000,
onSuccess: (data) => {
try {
showToast(t("something_went_wrong_on_our_end"), "error");
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
data.booking?.metadata || {}
)?.videoCallUrl;
if (locationVideoCallUrl) {
router.push(locationVideoCallUrl);
} else {
showToast(t("something_went_wrong_on_our_end"), "error");
}
} catch (err) {
showToast(t("something_went_wrong_on_our_end"), "error");
}
},
}
);
const createBookingMutation = useMutation(createBooking, {
onSuccess: (responseData) => {
const { uid, paymentUid } = responseData;
const fullName = getFullName(bookingForm.getValues("responses.name"));
if (paymentUid) {
router.push(
createPaymentLink({
paymentUid,
date: timeslot,
name: fullName,
email: bookingForm.getValues("responses.email"),
absolute: false,
})
);
}
if (!uid) {
console.error("No uid returned from createBookingMutation");
return;
}
const query = {
isSuccessBookingPage: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventSlug,
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
formerTime:
isRescheduling && bookingData?.startTime ? dayjs(bookingData.startTime).toString() : undefined,
};
bookingSuccessRedirect({
successRedirectUrl: event?.data?.successRedirectUrl || "",
query,
booking: responseData,
});
},
onError: (err, _, ctx) => {
// TODO:
// const vercelId = ctx?.meta?.headers?.get("x-vercel-id");
// if (vercelId) {
// setResponseVercelIdHeader(vercelId);
// }
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
},
});
const createInstantBookingMutation = useMutation(createInstantBooking, {
onSuccess: (responseData) => {
updateQueryParam("bookingId", responseData.bookingId);
setExpiryTime(responseData.expires);
},
onError: (err, _, ctx) => {
console.error("Error creating instant booking", err);
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
},
});
const createRecurringBookingMutation = useMutation(createRecurringBooking, {
onSuccess: async (responseData) => {
const booking = responseData[0] || {};
const { uid } = booking;
if (!uid) {
console.error("No uid returned from createRecurringBookingMutation");
return;
}
const query = {
isSuccessBookingPage: true,
allRemainingBookings: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventSlug,
formerTime:
isRescheduling && bookingData?.startTime ? dayjs(bookingData.startTime).toString() : undefined,
};
bookingSuccessRedirect({
successRedirectUrl: event?.data?.successRedirectUrl || "",
query,
booking,
});
},
});
const handleBookEvent = () => {
const values = bookingForm.getValues();
if (timeslot) {
// Clears form values stored in store, so old values won't stick around.
setFormValues({});
bookingForm.clearErrors();
// It shouldn't be possible that this method is fired without having event data,
// but since in theory (looking at the types) it is possible, we still handle that case.
if (!event?.data) {
bookingForm.setError("globalError", { message: t("error_booking_event") });
return;
}
// Ensures that duration is an allowed value, if not it defaults to the
// default event duration.
const validDuration = event.data.isDynamic
? duration || event.data.length
: duration && event.data.metadata?.multipleDuration?.includes(duration)
? duration
: event.data.length;
const bookingInput = {
values,
duration: validDuration,
event: event.data,
date: timeslot,
timeZone: timezone,
language: i18n.language,
rescheduleUid: rescheduleUid || undefined,
bookingUid: (bookingData && bookingData.uid) || seatedEventData?.bookingUid || undefined,
username: username || "",
metadata: Object.keys(routerQuery)
.filter((key) => key.startsWith("metadata"))
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: searchParams?.get(key),
}),
{}
),
hashedLink,
};
if (isInstantMeeting) {
createInstantBookingMutation.mutate(mapBookingToMutationInput(bookingInput));
} else if (event.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
createRecurringBookingMutation.mutate(
mapRecurringBookingToMutationInput(bookingInput, recurringEventCount)
);
} else {
createBookingMutation.mutate(mapBookingToMutationInput(bookingInput));
}
// Clears form values stored in store, so old values won't stick around.
setFormValues({});
bookingForm.clearErrors();
}
};
const beforeVerifyEmail = () => {
bookingForm.clearErrors();
// It shouldn't be possible that this method is fired without having event data,
// but since in theory (looking at the types) it is possible, we still handle that case.
if (!event?.data) {
bookingForm.setError("globalError", { message: t("error_booking_event") });
return;
}
};
const errors = {
hasFormErrors: Boolean(
createBookingMutation.isError ||
createRecurringBookingMutation.isError ||
createInstantBookingMutation.isError ||
bookingForm.formState.errors["globalError"]
),
formErrors: bookingForm.formState.errors["globalError"],
dataErrors:
createBookingMutation.error ||
createRecurringBookingMutation.error ||
createInstantBookingMutation.error,
};
const loadingStates = {
creatingBooking: createBookingMutation.isLoading,
creatingRecurringBooking: createRecurringBookingMutation.isLoading,
creatingInstantBooking: createInstantBookingMutation.isLoading,
};
return {
handleBookEvent,
expiryTime,
bookingForm,
bookerFormErrorRef,
initialValues,
key,
errors,
loadingStates,
hasInstantMeetingTokenExpired: Boolean(hasInstantMeetingTokenExpired),
formEmail: email,
formName: name,
beforeVerifyEmail,
};
};

View File

@ -0,0 +1,126 @@
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import type { z } from "zod";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import type { useEvent } from "@calcom/features/bookings/Booker/utils/event";
import type getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
export type useInitialFormValuesReturnType = ReturnType<typeof useInitialFormValues>;
export function useInitialFormValues({
eventType,
rescheduleUid,
isRescheduling,
}: {
eventType: ReturnType<typeof useEvent>["data"];
rescheduleUid: string | null;
isRescheduling: boolean;
}) {
const [initialValues, setDefaultValues] = useState<Record<string, unknown>>({});
const bookingData = useBookerStore((state) => state.bookingData);
const formValues = useBookerStore((state) => state.formValues);
const searchParams = useSearchParams();
const routerQuery = useRouterQuery();
const session = useSession();
useEffect(() => {
(async function () {
if (Object.keys(formValues).length) {
setDefaultValues(formValues);
return;
}
if (!eventType?.bookingFields) {
return {};
}
const querySchema = getBookingResponsesPartialSchema({
eventType: {
bookingFields: eventType.bookingFields,
},
view: rescheduleUid ? "reschedule" : "booking",
});
// Routing Forms don't support Split full name(because no Form Builder in there), so user needs to create two fields in there themselves. If they name these fields, `firstName` and `lastName`, we can prefill the Booking Form with them
// Once we support formBuilder in Routing Forms, we should be able to forward JSON form of name field value to Booking Form and prefill it there without having these two query params separately.
const firstNameQueryParam = searchParams?.get("firstName");
const lastNameQueryParam = searchParams?.get("lastName");
const parsedQuery = await querySchema.parseAsync({
...routerQuery,
name:
searchParams?.get("name") ||
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
// `guest` because we need to support legacy URL with `guest` query param support
// `guests` because the `name` of the corresponding bookingField is `guests`
guests: searchParams?.getAll("guests") || searchParams?.getAll("guest"),
});
const defaultUserValues = {
email:
rescheduleUid && bookingData && bookingData.attendees.length > 0
? bookingData?.attendees[0].email
: !!parsedQuery["email"]
? parsedQuery["email"]
: session.data?.user?.email ?? "",
name:
rescheduleUid && bookingData && bookingData.attendees.length > 0
? bookingData?.attendees[0].name
: !!parsedQuery["name"]
? parsedQuery["name"]
: session.data?.user?.name ?? session.data?.user?.username ?? "",
};
if (!isRescheduling) {
const defaults = {
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
return {
...responses,
[field.name]: parsedQuery[field.name] || undefined,
};
}, {});
defaults.responses = {
...responses,
name: defaultUserValues.name,
email: defaultUserValues.email,
};
setDefaultValues(defaults);
}
if (!rescheduleUid && !bookingData) {
return {};
}
// We should allow current session user as default values for booking form
const defaults = {
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
return {
...responses,
[field.name]: bookingData?.responses[field.name],
};
}, {});
defaults.responses = {
...responses,
name: defaultUserValues.name,
email: defaultUserValues.email,
};
setDefaultValues(defaults);
})();
}, [eventType?.bookingFields, formValues, isRescheduling, bookingData, rescheduleUid]);
// When initialValues is available(after doing async schema parsing) or session is available(so that we can prefill logged-in user email and name), we need to reset the form with the initialValues
const key = `${Object.keys(initialValues).length}_${session ? 1 : 0}`;
return { initialValues, key };
}

View File

@ -0,0 +1,56 @@
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId";
import type { useEventReturnType } from "@calcom/features/bookings/Booker/utils/event";
import { trpc } from "@calcom/trpc";
export const useSlots = (event: useEventReturnType) => {
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const [selectedTimeslot, setSelectedTimeslot] = useBookerStore(
(state) => [state.selectedTimeslot, state.setSelectedTimeslot],
shallow
);
const [slotReservationId, setSlotReservationId] = useSlotReservationId();
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation({
trpc: {
context: {
skipBatch: true,
},
},
onSuccess: (data) => {
setSlotReservationId(data.uid);
},
});
const removeSelectedSlot = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation({
trpc: { context: { skipBatch: true } },
});
const handleRemoveSlot = () => {
if (event?.data) {
removeSelectedSlot.mutate({ uid: slotReservationId });
}
};
const handleReserveSlot = () => {
if (event?.data?.id && selectedTimeslot && (selectedDuration || event?.data?.length)) {
reserveSlotMutation.mutate({
slotUtcStartDate: dayjs(selectedTimeslot).utc().format(),
eventTypeId: event.data.id,
slotUtcEndDate: dayjs(selectedTimeslot)
.utc()
.add(selectedDuration || event.data.length, "minutes")
.format(),
});
}
};
return {
selectedTimeslot,
setSelectedTimeslot,
setSlotReservationId,
slotReservationId,
handleReserveSlot,
handleRemoveSlot,
};
};

View File

@ -0,0 +1,55 @@
import { useState } from "react";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { showToast } from "@calcom/ui";
export interface IUseVerifyEmailProps {
email: string;
onVerifyEmail?: () => void;
name?: string | { firstName: string; lastname?: string };
requiresBookerEmailVerification?: boolean;
}
export const useVerifyEmail = ({
email,
name,
requiresBookerEmailVerification,
onVerifyEmail,
}: IUseVerifyEmailProps) => {
const [isEmailVerificationModalVisible, setEmailVerificationModalVisible] = useState(false);
const verifiedEmail = useBookerStore((state) => state.verifiedEmail);
const setVerifiedEmail = useBookerStore((state) => state.setVerifiedEmail);
const { t } = useLocale();
const sendEmailVerificationByCodeMutation = trpc.viewer.auth.sendVerifyEmailCode.useMutation({
onSuccess() {
showToast(t("email_sent"), "success");
},
onError() {
showToast(t("email_not_sent"), "error");
},
});
const handleVerifyEmail = () => {
onVerifyEmail?.();
sendEmailVerificationByCodeMutation.mutate({
email,
username: typeof name === "string" ? name : name?.firstName,
});
setEmailVerificationModalVisible(true);
};
const renderConfirmNotVerifyEmailButtonCond =
!requiresBookerEmailVerification || (email && verifiedEmail && verifiedEmail === email);
return {
handleVerifyEmail,
isEmailVerificationModalVisible,
setEmailVerificationModalVisible,
setVerifiedEmail,
renderConfirmNotVerifyEmailButtonCond: Boolean(renderConfirmNotVerifyEmailButtonCond),
};
};

View File

@ -8,6 +8,9 @@ import { trpc } from "@calcom/trpc/react";
import { useTimePreferences } from "../../lib/timePreferences";
import { useBookerStore } from "../store";
export type useEventReturnType = ReturnType<typeof useEvent>;
export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEvent>;
/**
* Wrapper hook around the trpc query that fetches
* the event curently viewed in the booker. It will get

View File

@ -5,7 +5,7 @@ import { parseRecurringDates } from "@calcom/lib/parse-dates";
import type { PublicEvent, BookingCreateBody, RecurringBookingCreateBody } from "../../types";
type BookingOptions = {
export type BookingOptions = {
values: Record<string, unknown>;
event: PublicEvent;
date: string;

View File

@ -1,8 +1,8 @@
import { post } from "@calcom/lib/fetch-wrapper";
import type { BookingCreateBody, InstatBookingResponse } from "../types";
import type { BookingCreateBody, InstantBookingResponse } from "../types";
export const createInstantBooking = async (data: BookingCreateBody) => {
const response = await post<BookingCreateBody, InstatBookingResponse>("/api/book/instant-event", data);
const response = await post<BookingCreateBody, InstantBookingResponse>("/api/book/instant-event", data);
return response;
};

View File

@ -33,6 +33,6 @@ export type BookingResponse = Awaited<
ReturnType<typeof import("@calcom/features/bookings/lib/handleNewBooking").default>
>;
export type InstatBookingResponse = Awaited<
export type InstantBookingResponse = Awaited<
ReturnType<typeof import("@calcom/features/instant-meeting/handleInstantMeeting").default>
>;