+2![Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)









Rajiv Sahal
GitHub
rajiv@cal.com <sahalrajiv6900@gmail.com>
rajiv@cal.com <sahalrajiv6900@gmail.com>
rajiv@cal.com <sahalrajiv6900@gmail.com>
rajiv@cal.com <sahalrajiv6900@gmail.com>
rajiv@cal.com <sahalrajiv6900@gmail.com>
rajiv@cal.com <sahalrajiv6900@gmail.com>
rajiv@cal.com <sahalrajiv6900@gmail.com>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Peer Richelsen
Hariom Balhara
8c39210a12
* chore: add new view for two step slot selection for embed * fix: make sure two step slot selection is false by default * chore: update embed playground to showcase two step slot selection * chore: add tests * chore: update embed snippet generator code to include two step slot selection * chore: update docs * fix: back button styling * chore: implement feedback from cubic * fix: add missing isEnableTwoStepSlotSelectionVisible properties to test mock store Co-Authored-By: unknown <> * chore: implement PR feedback * chore: update slot selection modal * chore: add prop to hide available times header * fix: scope two-step slot selection visibility to mobile only Restores the isMobile check in the timeslot visibility guard to ensure desktop embeds continue to show the booking UI when two-step slot selection is enabled. The feature should only hide the timeslot list on mobile devices. Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> * fixup: use translations for loading instead of actual word * fix: type check * fix: add window.matchMedia mock for jsdom test environment Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: add window.matchMedia mock to packages/testing/src/setupVitest.ts Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: unit tests failing * add a width style to the two-step slot selection embed container. * refactor: rename enableTwoStepSlotSelection to useSlotsViewOnSmallScreen - Rename external-facing embed config option from enableTwoStepSlotSelection to useSlotsViewOnSmallScreen - Update URL parameter parsing in embed-iframe.ts - Update type definitions in types.ts and index.d.ts - Update internal variable names to slotsViewOnSmallScreen for consistency - Update documentation, playground examples, and tests Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * Add config to prefill all booking fields so that slot has confirm button along with it * chore: implement PR feedback * fix: move twoStepSlotSelection embed outside misc-embeds div to fix e2e test The e2e test was failing because the misc-embeds div content was intercepting pointer events on mobile viewport. This follows the same pattern used for skeletonDemo - the embed is now outside misc-embeds and the div is hidden when testing this namespace. Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: prevent pointer event interception in twoStepSlotSelection embed Hide heading and note elements and set pointer-events: none on container elements while keeping pointer-events: auto on the iframe container. This prevents the container from intercepting clicks on the iframe during mobile viewport e2e tests. Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: hide all non-essential content for twoStepSlotSelection e2e test Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: disable pointer-events on body for twoStepSlotSelection e2e test Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: remove pointer-events: auto on container to let clicks pass through to iframe Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: explicitly set pointer-events: none on container and inline-embed-container Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: also set pointer-events: none on html element to prevent interception Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: make .place div fill viewport for twoStepSlotSelection e2e test Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: failing embed tests * fixup * fixup fixup * fix: failing tests * fix: update checks for verifying prefilled date --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useSession } from "next-auth/react";
|
|
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
|
import { useMemo, useCallback, useEffect } from "react";
|
|
import React from "react";
|
|
import { shallow } from "zustand/shallow";
|
|
|
|
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
|
import { useBookerEmbedEvents } from "@calcom/embed-core/src/embed-iframe/react-hooks";
|
|
import type { BookerProps } from "@calcom/features/bookings/Booker";
|
|
import {
|
|
BookerStoreProvider,
|
|
useInitializeBookerStoreContext,
|
|
useBookerStoreContext,
|
|
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
|
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
|
|
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
|
|
import { useBookings } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
|
|
import { useCalendars } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
|
|
import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useSlots";
|
|
import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
|
|
import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
|
|
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
|
|
import {
|
|
useEvent,
|
|
useScheduleForEvent,
|
|
} from "@calcom/features/bookings/Booker/utils/event";
|
|
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
|
|
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
|
|
import {
|
|
DEFAULT_LIGHT_BRAND_COLOR,
|
|
DEFAULT_DARK_BRAND_COLOR,
|
|
WEBAPP_URL,
|
|
} from "@calcom/lib/constants";
|
|
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
|
import { localStorage } from "@calcom/lib/webstorage";
|
|
|
|
import { Booker as BookerComponent } from "./Booker";
|
|
|
|
export type BookerWebWrapperAtomProps = BookerProps & {
|
|
eventData?: NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>;
|
|
};
|
|
|
|
const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const clientFetchedEvent = useEvent({
|
|
disabled: !!props.eventData,
|
|
fromRedirectOfNonOrgLink: props.entity.fromRedirectOfNonOrgLink,
|
|
});
|
|
const event = props.eventData
|
|
? {
|
|
data: props.eventData,
|
|
isSuccess: true,
|
|
isError: false,
|
|
isPending: false,
|
|
}
|
|
: clientFetchedEvent;
|
|
|
|
const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts);
|
|
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
|
|
const isRedirect = searchParams?.get("redirected") === "true" || false;
|
|
const fromUserNameRedirected = searchParams?.get("username") || "";
|
|
const rescheduleUid =
|
|
typeof window !== "undefined"
|
|
? new URLSearchParams(window.location.search).get("rescheduleUid")
|
|
: null;
|
|
const rescheduledBy =
|
|
typeof window !== "undefined"
|
|
? new URLSearchParams(window.location.search).get("rescheduledBy")
|
|
: null;
|
|
const bookingUid =
|
|
typeof window !== "undefined"
|
|
? new URLSearchParams(window.location.search).get("bookingUid")
|
|
: null;
|
|
const timezone = searchParams?.get("cal.tz") || null;
|
|
|
|
useEffect(() => {
|
|
// This event isn't processed by BookingPageTagManager because BookingPageTagManager hasn't loaded when it is fired. I think we should have a queue in fire method to handle this.
|
|
sdkActionManager?.fire("navigatedToBooker", {});
|
|
}, []);
|
|
useInitializeBookerStore({
|
|
...props,
|
|
eventId: props.entity.eventTypeId ?? event?.data?.id,
|
|
rescheduleUid,
|
|
rescheduledBy,
|
|
bookingUid: bookingUid,
|
|
layout: bookerLayout.isMobile ? "mobile" : bookerLayout.defaultLayout,
|
|
org: props.entity.orgSlug,
|
|
timezone,
|
|
});
|
|
useInitializeBookerStoreContext({
|
|
...props,
|
|
eventId: props.entity.eventTypeId ?? event?.data?.id,
|
|
rescheduleUid,
|
|
rescheduledBy,
|
|
bookingUid: bookingUid,
|
|
layout: bookerLayout.isMobile ? "mobile" : bookerLayout.defaultLayout,
|
|
org: props.entity.orgSlug,
|
|
timezone,
|
|
});
|
|
|
|
const [dayCount] = useBookerStoreContext(
|
|
(state) => [state.dayCount, state.setDayCount],
|
|
shallow
|
|
);
|
|
|
|
const { data: session } = useSession();
|
|
const routerQuery = useRouterQuery();
|
|
const hasSession = !!session;
|
|
const firstNameQueryParam = searchParams?.get("firstName");
|
|
const lastNameQueryParam = searchParams?.get("lastName");
|
|
const metadata = Object.keys(routerQuery)
|
|
.filter((key) => key.startsWith("metadata"))
|
|
.reduce(
|
|
(metadata, key) => ({
|
|
...metadata,
|
|
[key.substring("metadata[".length, key.length - 1)]:
|
|
searchParams?.get(key),
|
|
}),
|
|
{}
|
|
);
|
|
const prefillFormParams = useMemo(() => {
|
|
return {
|
|
name:
|
|
searchParams?.get("name") ||
|
|
(firstNameQueryParam
|
|
? `${firstNameQueryParam} ${lastNameQueryParam}`
|
|
: null),
|
|
guests:
|
|
(searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
|
|
};
|
|
}, [searchParams, firstNameQueryParam, lastNameQueryParam]);
|
|
|
|
const bookerForm = useBookingForm({
|
|
event: event.data,
|
|
sessionEmail: session?.user.email,
|
|
sessionUsername: session?.user.username,
|
|
sessionName: session?.user.name,
|
|
hasSession,
|
|
extraOptions: routerQuery,
|
|
prefillFormParams,
|
|
});
|
|
const calendars = useCalendars({ hasSession });
|
|
const verifyEmail = useVerifyEmail({
|
|
email: bookerForm.formEmail,
|
|
name: bookerForm.formName,
|
|
requiresBookerEmailVerification:
|
|
event?.data?.requiresBookerEmailVerification,
|
|
onVerifyEmail: bookerForm.beforeVerifyEmail,
|
|
});
|
|
const slots = useSlots(
|
|
event?.data ? { id: event.data.id, length: event.data.length } : null
|
|
);
|
|
|
|
const isEmbed = useIsEmbed();
|
|
|
|
/**
|
|
* Prioritize dateSchedule load
|
|
* Component will render but use data already fetched from here, and no duplicate requests will be made
|
|
* */
|
|
const schedule = useScheduleForEvent({
|
|
eventId: props.entity.eventTypeId ?? event.data?.id,
|
|
username: props.username,
|
|
dayCount,
|
|
eventSlug: props.eventSlug,
|
|
month: props.month,
|
|
duration: props.duration,
|
|
selectedDate,
|
|
teamMemberEmail: props.teamMemberEmail,
|
|
fromRedirectOfNonOrgLink: props.entity.fromRedirectOfNonOrgLink,
|
|
isTeamEvent: props.isTeamEvent ?? !!event.data?.team,
|
|
useApiV2: props.useApiV2,
|
|
bookerLayout,
|
|
...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}),
|
|
});
|
|
const bookings = useBookings({
|
|
event,
|
|
hashedLink: props.hashedLink,
|
|
bookingForm: bookerForm.bookingForm,
|
|
metadata: metadata ?? {},
|
|
teamMemberEmail: props.teamMemberEmail,
|
|
});
|
|
|
|
useBookerEmbedEvents({
|
|
eventId: event.data?.id,
|
|
eventSlug: event.data?.slug,
|
|
schedule,
|
|
});
|
|
|
|
const verifyCode = useVerifyCode({
|
|
onSuccess: () => {
|
|
if (!bookerForm.formEmail) return;
|
|
|
|
verifyEmail.setVerifiedEmail(bookerForm.formEmail);
|
|
verifyEmail.setEmailVerificationModalVisible(false);
|
|
bookings.handleBookEvent();
|
|
},
|
|
});
|
|
|
|
// Toggle query param for overlay calendar
|
|
const onOverlaySwitchStateChange = useCallback(
|
|
(state: boolean) => {
|
|
const url = new URL(window.location.href);
|
|
if (state) {
|
|
url.searchParams.set("overlayCalendar", "true");
|
|
localStorage.setItem("overlayCalendarSwitchDefault", "true");
|
|
} else {
|
|
url.searchParams.delete("overlayCalendar");
|
|
localStorage.removeItem("overlayCalendarSwitchDefault");
|
|
}
|
|
router.push(`${url.pathname}${url.search}`);
|
|
},
|
|
[router]
|
|
);
|
|
useBrandColors({
|
|
brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
|
|
darkBrandColor:
|
|
event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
|
|
theme: event.data?.profile.theme,
|
|
});
|
|
|
|
const areInstantMeetingParametersSet = Boolean(
|
|
event.data?.instantMeetingParameters &&
|
|
searchParams &&
|
|
event.data.instantMeetingParameters?.every?.((param) =>
|
|
Array.from(searchParams.values()).includes(param)
|
|
)
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (hasSession) onOverlaySwitchStateChange(true);
|
|
}, [hasSession]);
|
|
|
|
return (
|
|
<BookerComponent
|
|
{...props}
|
|
onGoBackInstantMeeting={() => {
|
|
if (pathname) window.location.href = pathname;
|
|
}}
|
|
onConnectNowInstantMeeting={() => {
|
|
const newPath = `${pathname}?isInstantMeeting=true`;
|
|
|
|
if (isEmbed) {
|
|
const fullUrl = `${new URL(document.URL).origin}/${newPath}`;
|
|
window.open(fullUrl, "_blank", "noopener,noreferrer");
|
|
} else {
|
|
router.push(newPath);
|
|
}
|
|
}}
|
|
onOverlayClickNoCalendar={() => {
|
|
router.push("/apps/categories/calendar");
|
|
}}
|
|
onClickOverlayContinue={() => {
|
|
const newUrl = new URL(`${WEBAPP_URL}/login`);
|
|
newUrl.searchParams.set("callbackUrl", window.location.pathname);
|
|
newUrl.searchParams.set("overlayCalendar", "true");
|
|
router.push(newUrl.toString());
|
|
}}
|
|
onOverlaySwitchStateChange={onOverlaySwitchStateChange}
|
|
sessionUsername={session?.user.username}
|
|
isRedirect={isRedirect}
|
|
fromUserNameRedirected={fromUserNameRedirected}
|
|
rescheduleUid={rescheduleUid}
|
|
rescheduledBy={rescheduledBy}
|
|
bookingUid={bookingUid}
|
|
hasSession={hasSession}
|
|
hasValidLicense={session?.hasValidLicense ?? false}
|
|
extraOptions={routerQuery}
|
|
bookings={bookings}
|
|
calendars={calendars}
|
|
slots={slots}
|
|
verifyEmail={verifyEmail}
|
|
bookerForm={bookerForm}
|
|
event={event}
|
|
bookerLayout={bookerLayout}
|
|
schedule={schedule}
|
|
verifyCode={verifyCode}
|
|
isPlatform={false}
|
|
areInstantMeetingParametersSet={areInstantMeetingParametersSet}
|
|
userLocale={session?.user.locale}
|
|
renderCaptcha
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
|
|
return (
|
|
<BookerStoreProvider>
|
|
<BookerWebWrapperComponent {...props} />
|
|
</BookerStoreProvider>
|
|
);
|
|
};
|