refactor: useBookings, useOverlayCalendar

This commit is contained in:
Morgan Vernay 2024-01-12 14:25:07 +02:00
parent c9ca50fe85
commit a893c22ea9
8 changed files with 470 additions and 393 deletions

View File

@ -21,11 +21,16 @@ import { EventMeta } from "./components/EventMeta";
import { Header } from "./components/Header";
import { InstantBooking } from "./components/InstantBooking";
import { LargeCalendar } from "./components/LargeCalendar";
import { OverlayCalendarContinueModal } from "./components/OverlayCalendar/OverlayCalendarContinueModal";
import { OverlayCalendarSettingsModal } from "./components/OverlayCalendar/OverlayCalendarSettingsModal";
import { OverlayCalendarSwitch } from "./components/OverlayCalendar/OverlayCalendarSwitch";
import { RedirectToInstantMeetingModal } from "./components/RedirectToInstantMeetingModal";
import { BookerSection } from "./components/Section";
import { Away, NotFound } from "./components/Unavailable";
import { useBookerLayout } from "./components/hooks/useBookerLayout";
import { useBookingForm } from "./components/hooks/useBookingForm";
import { useBookings } from "./components/hooks/useBookings";
import { useOverlayCalendar } from "./components/hooks/useOverlayCalendar";
import { useSlots } from "./components/hooks/useSlots";
import { useVerifyEmail } from "./components/hooks/useVerifyEmail";
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
@ -161,19 +166,21 @@ const BookerComponent = ({
});
const {
handleBookEvent,
bookerFormErrorRef,
key,
errors,
loadingStates,
hasInstantMeetingTokenExpired,
formEmail,
formName,
bookingForm,
beforeVerifyEmail,
errors: formErrors,
} = useBookingForm({
event,
});
const { handleBookEvent, hasInstantMeetingTokenExpired, errors, loadingStates } = useBookings({
event,
hashedLink,
bookingForm,
});
const {
@ -189,6 +196,17 @@ const BookerComponent = ({
onVerifyEmail: beforeVerifyEmail,
});
const {
isOverlayCalendarEnabled,
connectedCalendars,
loadingConnectedCalendar,
handleCloseContinueModal,
handleCloseSettingsModal,
isOpenOverlayContinueModal,
isOpenOverlaySettingsModal,
handleToggleConnectedCalendar,
} = useOverlayCalendar();
useEffect(() => {
if (event.isLoading) return setBookerState("loading");
if (!selectedDate) return setBookerState("selecting_date");
@ -219,7 +237,7 @@ const BookerComponent = ({
}}
onSubmit={renderConfirmNotVerifyEmailButtonCond ? handleBookEvent : handleVerifyEmail}
errorRef={bookerFormErrorRef}
errors={errors}
errors={{ ...formErrors, ...errors }}
loadingStates={loadingStates}
renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond}
bookingForm={bookingForm as unknown as UseFormReturn<FieldValues, any>}
@ -308,6 +326,26 @@ const BookerComponent = ({
extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays}
isMobile={isMobile}
nextSlots={nextSlots}
renderOverlay={() =>
isEmbed ? (
<></>
) : (
<>
<OverlayCalendarSwitch enabled={isOverlayCalendarEnabled} />
<OverlayCalendarContinueModal
open={isOpenOverlayContinueModal}
onClose={handleCloseContinueModal}
/>
<OverlayCalendarSettingsModal
connectedCalendars={connectedCalendars}
open={isOpenOverlaySettingsModal}
onClose={handleCloseSettingsModal}
isLoading={loadingConnectedCalendar}
onToggleConnectedCalendar={handleToggleConnectedCalendar}
/>
</>
)
}
/>
</BookerSection>
)}

View File

@ -1,7 +1,3 @@
import type {
IUseBookingFormErrors,
IUseBookingFormLoadingStates,
} from "bookings/Booker/components/hooks/useBookingForm";
import type { TFunction } from "next-i18next";
import { useState } from "react";
import type { FieldError } from "react-hook-form";
@ -14,6 +10,8 @@ import { Calendar } from "@calcom/ui/components/icon";
import { useBookerStore } from "../../store";
import type { useEventReturnType } from "../../utils/event";
import type { useBookingFormReturnType } from "../hooks/useBookingForm";
import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBookings";
import { BookingFields } from "./BookingFields";
import { FormSkeleton } from "./Skeleton";
@ -21,8 +19,8 @@ type BookEventFormProps = {
onCancel?: () => void;
onSubmit: () => void;
errorRef: React.RefObject<HTMLDivElement>;
errors: IUseBookingFormErrors;
loadingStates: IUseBookingFormLoadingStates;
errors: useBookingFormReturnType["errors"] & IUseBookingErrors;
loadingStates: IUseBookingLoadingStates;
children?: React.ReactNode;
bookingForm: UseFormReturn<FieldValues, any>;
renderConfirmNotVerifyEmailButtonCond: boolean;
@ -93,7 +91,7 @@ export const BookEventForm = ({
rescheduleUid={rescheduleUid || undefined}
bookingData={bookingData}
/>
{errors.hasFormErrors && (
{(errors.hasFormErrors || errors.hasDataErrors) && (
<div data-testid="booking-fail">
<Alert
ref={errorRef}

View File

@ -12,7 +12,6 @@ import { Calendar, Columns, Grid } from "@calcom/ui/components/icon";
import { TimeFormatToggle } from "../../components/TimeFormatToggle";
import { useBookerStore } from "../store";
import type { BookerLayout } from "../types";
import { OverlayCalendarContainer } from "./OverlayCalendar/OverlayCalendarContainer";
export function Header({
extraDays,
@ -21,6 +20,7 @@ export function Header({
nextSlots,
eventSlug,
isMyLink,
renderOverlay,
}: {
extraDays: number;
isMobile: boolean;
@ -28,6 +28,7 @@ export function Header({
nextSlots: number;
eventSlug: string;
isMyLink: boolean;
renderOverlay?: () => JSX.Element | null;
}) {
const { t, i18n } = useLocale();
@ -60,7 +61,6 @@ export function Header({
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} enabledLayouts={enabledLayouts} />
);
};
// In month view we only show the layout toggle.
if (isMonthView) {
return (
@ -75,7 +75,7 @@ export function Header({
</Button>
</Tooltip>
) : (
<OverlayCalendarContainer />
renderOverlay?.()
)}
<LayoutToggleWithData />
</div>
@ -136,7 +136,7 @@ export function Header({
</ButtonGroup>
</div>
<div className="ml-auto flex gap-2">
<OverlayCalendarContainer />
{renderOverlay?.()}
<TimeFormatToggle />
<div className="fixed top-4 ltr:right-4 rtl:left-4">
<LayoutToggleWithData />

View File

@ -1,10 +1,10 @@
import type { useOverlayCalendarReturnType } from "bookings/Booker/components/hooks/useOverlayCalendar";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Fragment, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Fragment } from "react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Alert,
Dialog,
@ -21,11 +21,13 @@ import {
import { Calendar } from "@calcom/ui/components/icon";
import { useLocalSet } from "../hooks/useLocalSet";
import { useOverlayCalendarStore } from "./store";
interface IOverlayCalendarContinueModalProps {
open?: boolean;
onClose?: (state: boolean) => void;
isLoading: boolean;
connectedCalendars: useOverlayCalendarReturnType["connectedCalendars"];
onToggleConnectedCalendar: (externalCalendarId: string, credentialId: number) => void;
}
const SkeletonLoader = () => {
@ -41,15 +43,14 @@ const SkeletonLoader = () => {
);
};
export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) {
const utils = trpc.useContext();
const [initalised, setInitalised] = useState(false);
const searchParams = useSearchParams();
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, {
enabled: !!props.open || Boolean(searchParams?.get("overlayCalendar")),
});
const { toggleValue, hasItem, set } = useLocalSet<{
export function OverlayCalendarSettingsModal({
connectedCalendars,
isLoading,
open,
onClose,
onToggledConnectedCalendar,
}: IOverlayCalendarContinueModalProps) {
const { hasItem } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
@ -57,24 +58,9 @@ export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModa
const router = useRouter();
const { t } = useLocale();
useEffect(() => {
if (data?.connectedCalendars && set.size === 0 && !initalised) {
data?.connectedCalendars.forEach((item) => {
item.calendars?.forEach((cal) => {
const id = { credentialId: item.credentialId, externalId: cal.externalId };
if (cal.primary) {
toggleValue(id);
}
});
});
setInitalised(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, hasItem, set, initalised]);
return (
<>
<Dialog open={props.open} onOpenChange={props.onClose}>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
enableOverflow
type="creation"
@ -86,7 +72,7 @@ export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModa
<SkeletonLoader />
) : (
<>
{data?.connectedCalendars.length === 0 ? (
{connectedCalendars.length === 0 ? (
<EmptyScreen
Icon={Calendar}
headline={t("no_calendar_installed")}
@ -96,7 +82,7 @@ export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModa
/>
) : (
<>
{data?.connectedCalendars.map((item) => (
{connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.error && !item.calendars && (
<Alert severity="error" title={item.error.message} />
@ -139,12 +125,7 @@ export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModa
externalId: cal.externalId,
})}
onCheckedChange={() => {
toggleValue({
credentialId: item.credentialId,
externalId: cal.externalId,
});
setOverlayBusyDates([]);
utils.viewer.availability.calendarOverlay.reset();
onToggledConnectedCalendar(cal.externalId, item.credentialId);
}}
/>
<label htmlFor={id}>{cal.name}</label>

View File

@ -1,29 +1,21 @@
import { useSession } from "next-auth/react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useCallback, useEffect } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
import { trpc } from "@calcom/trpc/react";
import { Button, Switch } from "@calcom/ui";
import { Settings } from "@calcom/ui/components/icon";
import { useBookerStore } from "../../store";
import { OverlayCalendarContinueModal } from "../OverlayCalendar/OverlayCalendarContinueModal";
import { OverlayCalendarSettingsModal } from "../OverlayCalendar/OverlayCalendarSettingsModal";
import { useLocalSet } from "../hooks/useLocalSet";
import { useOverlayCalendarStore } from "./store";
interface OverlayCalendarSwitchProps {
enabled?: boolean;
}
function OverlayCalendarSwitch({ enabled }: OverlayCalendarSwitchProps) {
export function OverlayCalendarSwitch({ enabled }: OverlayCalendarSwitchProps) {
const { t } = useLocale();
const setContinueWithProvider = useOverlayCalendarStore((state) => state.setContinueWithProviderModal);
const setCalendarSettingsOverlay = useOverlayCalendarStore(
@ -106,90 +98,3 @@ function OverlayCalendarSwitch({ enabled }: OverlayCalendarSwitchProps) {
</div>
);
}
export function OverlayCalendarContainer() {
const isEmbed = useIsEmbed();
const searchParams = useSearchParams();
const [continueWithProvider, setContinueWithProvider] = useOverlayCalendarStore(
(state) => [state.continueWithProviderModal, state.setContinueWithProviderModal],
shallow
);
const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useOverlayCalendarStore(
(state) => [state.calendarSettingsOverlayModal, state.setCalendarSettingsOverlayModal],
shallow
);
const { data: session } = useSession();
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
const switchEnabled =
searchParams?.get("overlayCalendar") === "true" ||
localStorage.getItem("overlayCalendarSwitchDefault") === "true";
const selectedDate = useBookerStore((state) => state.selectedDate);
const { timezone } = useTimePreferences();
// Move this to a hook
const { set, clearSet } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery(
{
loggedInUsersTz: timezone || "Europe/London",
dateFrom: selectedDate,
dateTo: selectedDate,
calendarsToLoad: Array.from(set).map((item) => ({
credentialId: item.credentialId,
externalId: item.externalId,
})),
},
{
enabled: !!session && set.size > 0 && switchEnabled,
onError: () => {
clearSet();
},
}
);
useEffect(() => {
if (overlayBusyDates) {
const nowDate = dayjs();
const usersTimezoneDate = nowDate.tz(timezone);
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
const offsettedArray = overlayBusyDates.map((item) => {
return {
...item,
start: dayjs(item.start).add(offset, "hours").toDate(),
end: dayjs(item.end).add(offset, "hours").toDate(),
};
});
setOverlayBusyDates(offsettedArray);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overlayBusyDates]);
if (isEmbed) {
return null;
}
return (
<>
<OverlayCalendarSwitch enabled={switchEnabled} />
<OverlayCalendarContinueModal
open={continueWithProvider}
onClose={(val) => {
setContinueWithProvider(val);
}}
/>
<OverlayCalendarSettingsModal
open={calendarSettingsOverlay}
onClose={(val) => {
setCalendarSettingsOverlay(val);
}}
/>
</>
);
}

View File

@ -1,71 +1,27 @@
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 { useRef } from "react";
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 type useBookingFormReturnType = ReturnType<typeof useBookingForm>;
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);
export const useBookingForm = ({ event }: IUseBookingForm) => {
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 { t } = useLocale();
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 bookingFormSchema = z
.object({
@ -108,178 +64,6 @@ export const useBookingForm = ({ event, hashedLink }: IUseBookingForm) => {
});
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();
@ -293,39 +77,18 @@ export const useBookingForm = ({ event, hashedLink }: IUseBookingForm) => {
};
const errors = {
hasFormErrors: Boolean(
createBookingMutation.isError ||
createRecurringBookingMutation.isError ||
createInstantBookingMutation.isError ||
bookingForm.formState.errors["globalError"]
),
hasFormErrors: Boolean(bookingForm.formState.errors["globalError"]),
formErrors: bookingForm.formState.errors["globalError"],
dataErrors:
createBookingMutation.error ||
createRecurringBookingMutation.error ||
createInstantBookingMutation.error,
};
// A redirect is triggered on mutation success, so keep the loading state while it is happening.
const loadingStates = {
creatingBooking: createBookingMutation.isLoading || createBookingMutation.isSuccess,
creatingRecurringBooking:
createRecurringBookingMutation.isLoading || createRecurringBookingMutation.isSuccess,
creatingInstantBooking: createInstantBookingMutation.isLoading,
};
return {
handleBookEvent,
expiryTime,
bookingForm,
bookerFormErrorRef,
initialValues,
key,
errors,
loadingStates,
hasInstantMeetingTokenExpired: Boolean(hasInstantMeetingTokenExpired),
formEmail: email,
formName: name,
beforeVerifyEmail,
formErrors: errors,
errors,
};
};

View File

@ -0,0 +1,278 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
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 { 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 type { useBookingFormReturnType } from "./useBookingForms";
import { useInitialFormValues } from "./useInitialFormValues";
export interface IUseBookings {
event: useEventReturnType;
hashedLink?: string | null;
bookingForm: useBookingFormReturnType["bookingForm"];
}
export interface IUseBookingLoadingStates {
creatingBooking: boolean;
creatingRecurringBooking: boolean;
creatingInstantBooking: boolean;
}
export interface IUseBookingErrors {
hasDataErrors: boolean;
dataErrors: unknown;
}
export const useBookings = ({ event, hashedLink, bookingForm }: IUseBookings) => {
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 isRescheduling = !!rescheduleUid && !!bookingData;
const { initialValues, key } = useInitialFormValues({
eventType: event.data,
rescheduleUid,
isRescheduling,
});
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 errors = {
hasDataErrors: Boolean(
createBookingMutation.isError ||
createRecurringBookingMutation.isError ||
createInstantBookingMutation.isError
),
dataErrors:
createBookingMutation.error ||
createRecurringBookingMutation.error ||
createInstantBookingMutation.error,
};
// A redirect is triggered on mutation success, so keep the loading state while it is happening.
const loadingStates = {
creatingBooking: createBookingMutation.isLoading || createBookingMutation.isSuccess,
creatingRecurringBooking:
createRecurringBookingMutation.isLoading || createRecurringBookingMutation.isSuccess,
creatingInstantBooking: createInstantBookingMutation.isLoading,
};
return {
handleBookEvent,
expiryTime,
bookingForm,
bookerFormErrorRef,
initialValues,
key,
errors,
loadingStates,
hasInstantMeetingTokenExpired: Boolean(hasInstantMeetingTokenExpired),
};
};

View File

@ -0,0 +1,114 @@
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { trpc } from "@calcom/trpc/react";
import { useBookerStore } from "../../store";
import { useOverlayCalendarStore } from "../OverlayCalendar/store";
import { useLocalSet } from "./useLocalSet";
export type useOverlayCalendarReturnType = ReturnType<typeof useOverlayCalendar>;
export const useOverlayCalendar = () => {
const utils = trpc.useContext();
const { set, toggleValue, hasItem, clearSet } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const { data: session } = useSession();
const searchParams = useSearchParams();
const [initalised, setInitalised] = useState(false);
const [continueWithProvider, setContinueWithProvider] = useOverlayCalendarStore(
(state) => [state.continueWithProviderModal, state.setContinueWithProviderModal],
shallow
);
const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useOverlayCalendarStore(
(state) => [state.calendarSettingsOverlayModal, state.setCalendarSettingsOverlayModal],
shallow
);
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
const switchEnabled =
searchParams?.get("overlayCalendar") === "true" ||
localStorage.getItem("overlayCalendarSwitchDefault") === "true";
const selectedDate = useBookerStore((state) => state.selectedDate);
const { timezone } = useTimePreferences();
const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery(
{
loggedInUsersTz: timezone || "Europe/London",
dateFrom: selectedDate,
dateTo: selectedDate,
calendarsToLoad: Array.from(set).map((item) => ({
credentialId: item.credentialId,
externalId: item.externalId,
})),
},
{
enabled: !!session && set.size > 0 && switchEnabled,
onError: () => {
clearSet();
},
}
);
useEffect(() => {
if (overlayBusyDates) {
const nowDate = dayjs();
const usersTimezoneDate = nowDate.tz(timezone);
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
const offsettedArray = overlayBusyDates.map((item) => {
return {
...item,
start: dayjs(item.start).add(offset, "hours").toDate(),
end: dayjs(item.end).add(offset, "hours").toDate(),
};
});
setOverlayBusyDates(offsettedArray);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overlayBusyDates]);
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, {
enabled: !!calendarSettingsOverlay || Boolean(searchParams?.get("overlayCalendar")),
});
useEffect(() => {
if (data?.connectedCalendars && set.size === 0 && !initalised) {
data?.connectedCalendars.forEach((item) => {
item.calendars?.forEach((cal) => {
const id = { credentialId: item.credentialId, externalId: cal.externalId };
if (cal.primary) {
toggleValue(id);
}
});
});
setInitalised(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, hasItem, set, initalised]);
const handleToggleConnectedCalendar = (externalCalendarId: string, credentialId: number) => {
toggleValue({
credentialId: credentialId,
externalId: externalCalendarId,
});
setOverlayBusyDates([]);
utils.viewer.availability.calendarOverlay.reset();
};
return {
isOverlayCalendarEnabled: switchEnabled,
connectedCalendars: data?.connectedCalendars || [],
loadingConnectedCalendar: isLoading,
isOpenOverlayContinueModal: continueWithProvider,
isOpenOverlaySettingsModal: calendarSettingsOverlay,
handleCloseContinueModal: (val: boolean) => setContinueWithProvider(val),
handleCloseSettingsModal: (val: boolean) => setCalendarSettingsOverlay(val),
handleToggleConnectedCalendar,
};
};