fix: scroll issues in Embed (#26583)

* 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>
This commit is contained in:
Rajiv Sahal
2026-01-22 00:14:33 +05:30
committed by GitHub
parent 6c620e37a6
commit 8c39210a12
29 changed files with 1420 additions and 260 deletions
@@ -1,7 +1,10 @@
import { useCallback, useMemo, useRef } from "react";
import dayjs from "@calcom/dayjs";
import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/web/modules/bookings/components/AvailableTimes";
import {
AvailableTimes,
AvailableTimesSkeleton,
} from "@calcom/web/modules/bookings/components/AvailableTimes";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
import type { BookerEvent } from "@calcom/features/bookings/types";
@@ -25,7 +28,10 @@ type AvailableTimeSlotsProps = {
seatsPerTimeSlot?: number | null;
showAvailableSeatsCount?: boolean | null;
event: {
data?: Pick<BookerEvent, "length" | "bookingFields" | "price" | "currency" | "metadata"> | null;
data?: Pick<
BookerEvent,
"length" | "bookingFields" | "price" | "currency" | "metadata"
> | null;
};
customClassNames?: {
availableTimeSlotsContainer?: string;
@@ -50,6 +56,7 @@ type AvailableTimeSlotsProps = {
unavailableTimeSlots: string[];
confirmButtonDisabled?: boolean;
onAvailableTimeSlotSelect: (time: string) => void;
hideAvailableTimesHeader?: boolean;
};
/**
@@ -74,19 +81,23 @@ export const AvailableTimeSlots = ({
confirmButtonDisabled,
confirmStepClassNames,
onAvailableTimeSlotSelect,
hideAvailableTimesHeader = false,
...props
}: AvailableTimeSlotsProps) => {
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const setSeatedEventData = useBookerStoreContext((state) => state.setSeatedEventData);
const setSeatedEventData = useBookerStoreContext(
(state) => state.setSeatedEventData
);
const date = selectedDate || dayjs().format("YYYY-MM-DD");
const [layout] = useBookerStoreContext((state) => [state.layout]);
const isColumnView = layout === BookerLayouts.COLUMN_VIEW;
const containerRef = useRef<HTMLDivElement | null>(null);
const { setTentativeSelectedTimeslots, tentativeSelectedTimeslots } = useBookerStoreContext((state) => ({
setTentativeSelectedTimeslots: state.setTentativeSelectedTimeslots,
tentativeSelectedTimeslots: state.tentativeSelectedTimeslots,
}));
const { setTentativeSelectedTimeslots, tentativeSelectedTimeslots } =
useBookerStoreContext((state) => ({
setTentativeSelectedTimeslots: state.setTentativeSelectedTimeslots,
tentativeSelectedTimeslots: state.tentativeSelectedTimeslots,
}));
const onTentativeTimeSelect = ({
time,
@@ -124,13 +135,22 @@ export const AvailableTimeSlots = ({
return [];
}, [date, extraDays, nonEmptyScheduleDaysFromSelectedDate]);
const { slotsPerDay, toggleConfirmButton } = useSlotsForAvailableDates(dates, scheduleData?.slots);
const { slotsPerDay, toggleConfirmButton } = useSlotsForAvailableDates(
dates,
scheduleData?.slots
);
const overlayCalendarToggled =
getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault");
getQueryParam("overlayCalendar") === "true" ||
localStorage.getItem("overlayCalendarSwitchDefault");
const onTimeSelect = useCallback(
(time: string, attendees: number, seatsPerTimeSlot?: number | null, bookingUid?: string) => {
(
time: string,
attendees: number,
seatsPerTimeSlot?: number | null,
bookingUid?: string
) => {
// Temporarily allow disabling it, till we are sure that it doesn't cause any significant load on the system
if (PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM) {
// Ensures that user has latest available slots when they are about to confirm the booking by filling up the details
@@ -181,12 +201,24 @@ export const AvailableTimeSlots = ({
);
}
},
[overlayCalendarToggled, onTimeSelect, seatsPerTimeSlot, skipConfirmStep, toggleConfirmButton]
[
overlayCalendarToggled,
onTimeSelect,
seatsPerTimeSlot,
skipConfirmStep,
toggleConfirmButton,
]
);
return (
<>
<div className={classNames(`flex`, `${customClassNames?.availableTimeSlotsContainer}`)}>
<div
className={classNames(
`flex`,
hideAvailableTimesHeader && "hidden",
`${customClassNames?.availableTimeSlotsContainer}`
)}
>
{isLoading ? (
<div className="mb-3 h-8" />
) : (
@@ -197,15 +229,19 @@ export const AvailableTimeSlots = ({
return (
<AvailableTimesHeader
customClassNames={{
availableTimeSlotsHeaderContainer: customClassNames?.availableTimeSlotsHeaderContainer,
availableTimeSlotsTitle: customClassNames?.availableTimeSlotsTitle,
availableTimeSlotsTimeFormatToggle: customClassNames?.availableTimeSlotsTimeFormatToggle,
availableTimeSlotsHeaderContainer:
customClassNames?.availableTimeSlotsHeaderContainer,
availableTimeSlotsTitle:
customClassNames?.availableTimeSlotsTitle,
availableTimeSlotsTimeFormatToggle:
customClassNames?.availableTimeSlotsTimeFormatToggle,
}}
key={slots.date}
date={dayjs(slots.date)}
showTimeFormatToggle={!isColumnView && !isOOODay}
availableMonth={
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM")
dayjs(selectedDate).format("MM") !==
dayjs(slots.date).format("MM")
? dayjs(slots.date).format("MMM")
: undefined
}
@@ -221,13 +257,19 @@ export const AvailableTimeSlots = ({
limitHeight && "no-scrollbar grow overflow-auto md:h-[400px]",
!limitHeight && "flex h-full w-full flex-row gap-4",
`${customClassNames?.availableTimeSlotsContainer}`
)}>
)}
>
{isLoading && // Shows exact amount of days as skeleton.
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => <AvailableTimesSkeleton key={i} />)}
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => (
<AvailableTimesSkeleton key={i} />
))}
{!isLoading &&
slotsPerDay.length > 0 &&
slotsPerDay.map((slots) => (
<div key={slots.date} className="no-scrollbar overflow-x-hidden! h-full w-full overflow-y-auto">
<div
key={slots.date}
className="no-scrollbar overflow-x-hidden! h-full w-full overflow-y-auto"
>
<AvailableTimes
className={customClassNames?.availableTimeSlotsContainer}
customClassNames={customClassNames?.availableTimes}
@@ -13,12 +13,30 @@ import "@calcom/lib/__mocks__/logger";
import React from "react";
import { vi } from "vitest";
vi.mock("next/navigation", async (importOriginal) => {
const actual = await importOriginal<typeof import("next/navigation")>();
return {
...actual,
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
};
});
import "@calcom/dayjs/__mocks__";
import "@calcom/features/auth/Turnstile";
import { Booker } from "./Booker";
import { render, screen } from "@calcom/features/bookings/Booker/__tests__/test-utils";
import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/Booker/types";
import { Booker } from "./Booker";
vi.mock("framer-motion", async (importOriginal) => {
const actual = (await importOriginal()) as any;
@@ -175,9 +193,12 @@ describe("Booker", () => {
});
it("should render null when in loading state", () => {
const { container } = render(<Booker {...defaultProps as unknown as BookerProps & WrappedBookerProps} />, {
mockStore: { state: "loading" },
});
const { container } = render(
<Booker {...(defaultProps as unknown as BookerProps & WrappedBookerProps)} />,
{
mockStore: { state: "loading" },
}
);
expect(container).toBeEmptyDOMElement();
});
@@ -194,7 +215,7 @@ describe("Booker", () => {
},
};
render(<Booker {...propsWithDryRun as unknown as BookerProps & WrappedBookerProps} />, {
render(<Booker {...(propsWithDryRun as unknown as BookerProps & WrappedBookerProps)} />, {
mockStore: {
state: "selecting_time",
selectedDate: "2024-01-01",
@@ -218,7 +239,7 @@ describe("Booker", () => {
},
};
render(<Booker {...propsWithInvalidate as unknown as BookerProps & WrappedBookerProps} />, {
render(<Booker {...(propsWithInvalidate as unknown as BookerProps & WrappedBookerProps)} />, {
mockStore: { state: "booking" },
});
screen.logTestingPlaygroundURL();
@@ -240,11 +261,11 @@ describe("Booker", () => {
},
};
render(<Booker {...propsWithQuickChecks as unknown as BookerProps & WrappedBookerProps} />, {
render(<Booker {...(propsWithQuickChecks as unknown as BookerProps & WrappedBookerProps)} />, {
mockStore: { state: "booking" },
});
const bookEventForm = screen.getByTestId("book-event-form");
await expect(bookEventForm).toHaveAttribute("data-unavailable", "true");
});
});
});
});
@@ -18,6 +18,7 @@ import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
import { isTimeSlotAvailable } from "@calcom/features/bookings/Booker/utils/isTimeslotAvailable";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils";
import {
@@ -28,6 +29,7 @@ import {
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import classNames from "@calcom/ui/classNames";
import { DialogContent } from "@calcom/ui/components/dialog";
import { UnpublishedEntity } from "@calcom/ui/components/unpublished-entity";
import PoweredBy from "@calcom/web/modules/ee/common/components/PoweredBy";
import { AnimatePresence, LazyMotion, m } from "framer-motion";
@@ -48,6 +50,7 @@ import { LargeCalendar } from "./LargeCalendar";
import { OverlayCalendar } from "./OverlayCalendar/OverlayCalendar";
import { RedirectToInstantMeetingModal } from "./RedirectToInstantMeetingModal";
import { BookerSection } from "./Section";
import { SlotSelectionModalHeader } from "./SlotSelectionModalHeader";
import { NotFound } from "./Unavailable";
import { VerifyCodeDialog } from "./VerifyCodeDialog";
@@ -99,6 +102,11 @@ const BookerComponent = ({
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const [isSlotSelectionModalVisible, setIsSlotSelectionModalVisible] = useBookerStoreContext(
(state) => [state.isSlotSelectionModalVisible, state.setIsSlotSelectionModalVisible],
shallow
);
const {
shouldShowFormInDialog,
hasDarkBackground,
@@ -109,6 +117,7 @@ const BookerComponent = ({
hideEventTypeDetails,
isEmbed,
bookerLayouts,
slotsViewOnSmallScreen,
} = bookerLayout;
const [seatedEventData, setSeatedEventData] = useBookerStoreContext(
@@ -181,6 +190,11 @@ const BookerComponent = ({
const scrolledToTimeslotsOnce = useRef(false);
const embedUiConfig = useEmbedUiConfig();
const scrollToTimeSlots = () => {
// Don't scroll if slots view on small screen is enabled
if (slotsViewOnSmallScreen) {
return;
}
if (
isMobile &&
!scrolledToTimeslotsOnce.current &&
@@ -213,6 +227,10 @@ const BookerComponent = ({
const onAvailableTimeSlotSelect = (time: string) => {
setSelectedTimeslot(time);
if (!skipConfirmStep) {
setIsSlotSelectionModalVisible(false);
}
};
updateEmbedBookerState({ bookerState, slotsQuery: schedule });
@@ -268,7 +286,11 @@ const BookerComponent = ({
schedule?.invalidate();
}
if (seatedEventData.bookingUid) {
setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined });
setSeatedEventData({
...seatedEventData,
bookingUid: undefined,
attendees: undefined,
});
}
}}
onSubmit={() => (renderConfirmNotVerifyEmailButtonCond ? handleBookEvent() : handleVerifyEmail())}
@@ -460,6 +482,11 @@ const BookerComponent = ({
isLoading={schedule.isPending}
scrollToTimeSlots={scrollToTimeSlots}
showNoAvailabilityDialog={showNoAvailabilityDialog}
onDateChange={() => {
if (slotsViewOnSmallScreen) {
setIsSlotSelectionModalVisible(true);
}
}}
/>
</div>
)}
@@ -493,7 +520,7 @@ const BookerComponent = ({
</BookerSection>
<BookerSection
key="large-calendar"
key="enable-calendar"
area="main"
visible={layout === BookerLayouts.WEEK_VIEW}
className="border-subtle sticky top-0 -ml-px h-full md:border-l"
@@ -505,12 +532,14 @@ const BookerComponent = ({
event={event}
/>
</BookerSection>
<BookerSection
key="timeslots"
area={{ default: "main", month_view: "timeslots" }}
visible={
(layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
layout === BookerLayouts.COLUMN_VIEW
!(slotsViewOnSmallScreen && isMobile) &&
((layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
layout === BookerLayouts.COLUMN_VIEW)
}
className={classNames(
"border-subtle rtl:border-default flex h-full w-full flex-col overflow-x-auto px-5 py-3 pb-0 rtl:border-r ltr:md:border-l",
@@ -618,6 +647,42 @@ const BookerComponent = ({
visible={bookerState === "booking" && shouldShowFormInDialog}>
{EventBooker}
</BookFormAsModal>
<Dialog open={isMobile && isSlotSelectionModalVisible}>
<DialogContent
type={undefined}
enableOverflow
className="fixed! inset-0! top-0! left-0! h-screen! max-h-screen! w-screen! max-w-none! translate-x-0! translate-y-0! rounded-none! m-0! px-8 pt-0 pb-8">
<SlotSelectionModalHeader
onClick={() => setIsSlotSelectionModalVisible(false)}
event={event.data}
isPlatform={isPlatform}
timeZones={timeZones}
selectedDate={selectedDate}
/>
<AvailableTimeSlots
onAvailableTimeSlotSelect={onAvailableTimeSlotSelect}
customClassNames={customClassNames?.availableTimeSlotsCustomClassNames}
extraDays={extraDays}
limitHeight={layout === BookerLayouts.MONTH_VIEW}
schedule={schedule}
isLoading={schedule.isPending}
seatsPerTimeSlot={event.data?.seatsPerTimeSlot}
unavailableTimeSlots={unavailableTimeSlots}
showAvailableSeatsCount={event.data?.seatsShowAvailabilityCount}
event={event}
loadingStates={loadingStates}
renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond}
isVerificationCodeSending={isVerificationCodeSending}
onSubmit={onSubmit}
skipConfirmStep={skipConfirmStep}
shouldRenderCaptcha={shouldRenderCaptcha}
watchedCfToken={watchedCfToken}
confirmButtonDisabled={confirmButtonDisabled}
confirmStepClassNames={customClassNames?.confirmStep}
hideAvailableTimesHeader
/>
</DialogContent>
</Dialog>
<Toaster position="bottom-right" />
</>
);
@@ -6,10 +6,7 @@ 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 { 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 {
@@ -25,10 +22,17 @@ import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useS
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 {
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 {
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";
@@ -48,11 +52,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
});
const event = props.eventData
? {
data: props.eventData,
isSuccess: true,
isError: false,
isPending: false,
}
data: props.eventData,
isSuccess: true,
isError: false,
isPending: false,
}
: clientFetchedEvent;
const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts);
@@ -60,11 +64,17 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
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;
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("rescheduleUid")
: null;
const rescheduledBy =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduledBy") : null;
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("rescheduledBy")
: null;
const bookingUid =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null;
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("bookingUid")
: null;
const timezone = searchParams?.get("cal.tz") || null;
useEffect(() => {
@@ -92,7 +102,10 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
timezone,
});
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
const [dayCount] = useBookerStoreContext(
(state) => [state.dayCount, state.setDayCount],
shallow
);
const { data: session } = useSession();
const routerQuery = useRouterQuery();
@@ -104,7 +117,8 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: searchParams?.get(key),
[key.substring("metadata[".length, key.length - 1)]:
searchParams?.get(key),
}),
{}
);
@@ -112,8 +126,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
return {
name:
searchParams?.get("name") ||
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
guests: (searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
(firstNameQueryParam
? `${firstNameQueryParam} ${lastNameQueryParam}`
: null),
guests:
(searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
};
}, [searchParams, firstNameQueryParam, lastNameQueryParam]);
@@ -130,10 +147,13 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const verifyEmail = useVerifyEmail({
email: bookerForm.formEmail,
name: bookerForm.formName,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
requiresBookerEmailVerification:
event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail,
});
const slots = useSlots(event?.data ? { id: event.data.id, length: event.data.length } : null);
const slots = useSlots(
event?.data ? { id: event.data.id, length: event.data.length } : null
);
const isEmbed = useIsEmbed();
@@ -197,16 +217,17 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
);
useBrandColors({
brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: event.data?.profile.darkBrandColor ?? DEFAULT_DARK_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)
)
searchParams &&
event.data.instantMeetingParameters?.every?.((param) =>
Array.from(searchParams.values()).includes(param)
)
);
useEffect(() => {
@@ -10,6 +10,7 @@ import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { User } from "@calcom/prisma/client";
import type { PeriodData } from "@calcom/types/Event";
import { useSlotsViewOnSmallScreen } from "@calcom/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen";
import type { Slots } from "@calcom/features/bookings/types";
@@ -32,8 +33,8 @@ const useMoveToNextMonthOnNoAvailability = ({
};
}
const nonEmptyScheduleDaysInBrowsingMonth = nonEmptyScheduleDays.filter((date) =>
dayjs(date).isSame(browsingDate, "month")
const nonEmptyScheduleDaysInBrowsingMonth = nonEmptyScheduleDays.filter(
(date) => dayjs(date).isSame(browsingDate, "month")
);
const moveToNextMonthOnNoAvailability = () => {
@@ -41,7 +42,10 @@ const useMoveToNextMonthOnNoAvailability = ({
const browsingMonth = browsingDate.format("YYYY-MM");
// Not meeting the criteria to move to next month
// Has to be currentMonth and it must have all days unbookable
if (currentMonth != browsingMonth || nonEmptyScheduleDaysInBrowsingMonth.length) {
if (
currentMonth != browsingMonth ||
nonEmptyScheduleDaysInBrowsingMonth.length
) {
return;
}
onMonthChange(browsingDate.add(1, "month"));
@@ -58,6 +62,7 @@ export const DatePicker = ({
classNames,
scrollToTimeSlots,
showNoAvailabilityDialog,
onDateChange,
}: {
event: {
data?: {
@@ -74,6 +79,7 @@ export const DatePicker = ({
classNames?: DatePickerClassNames;
scrollToTimeSlots?: () => void;
showNoAvailabilityDialog?: boolean;
onDateChange?: () => void;
}) => {
const { i18n } = useLocale();
const [month, selectedDate, layout] = useBookerStoreContext(
@@ -86,21 +92,26 @@ export const DatePicker = ({
shallow
);
const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen();
const onMonthChange = (date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate({ date: date.format("YYYY-MM-DD") });
if (!slotsViewOnSmallScreen) {
setSelectedDate({ date: date.format("YYYY-MM-DD") });
}
setDayCount(null); // Whenever the month is changed, we nullify getting X days
};
const nonEmptyScheduleDays = useNonEmptyScheduleDays(slots);
const browsingDate = month ? dayjs(month) : dayjs().startOf("month");
const { moveToNextMonthOnNoAvailability } = useMoveToNextMonthOnNoAvailability({
browsingDate,
nonEmptyScheduleDays,
onMonthChange,
isLoading: isLoading ?? true,
});
const { moveToNextMonthOnNoAvailability } =
useMoveToNextMonthOnNoAvailability({
browsingDate,
nonEmptyScheduleDays,
onMonthChange,
isLoading: isLoading ?? true,
});
moveToNextMonthOnNoAvailability();
// Determine if this is a compact sidebar view based on layout
@@ -134,11 +145,19 @@ export const DatePicker = ({
className={classNames?.datePickerContainer}
isLoading={isLoading}
onChange={(date: Dayjs | null, omitUpdatingParams?: boolean) => {
const newDate = date === null ? null : date.format("YYYY-MM-DD");
const previousDate = selectedDate;
const dateChanged = newDate !== previousDate;
setSelectedDate({
date: date === null ? date : date.format("YYYY-MM-DD"),
date: date === null ? null : date.format("YYYY-MM-DD"),
omitUpdatingParams,
preventMonthSwitching: !isCompact, // Prevent month switching when in monthly view
});
if (dateChanged) {
onDateChange?.();
}
}}
onMonthChange={onMonthChange}
includedDates={nonEmptyScheduleDays}
@@ -0,0 +1,138 @@
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { shallow } from "zustand/shallow";
import { Timezone as PlatformTimezoneSelect } from "@calcom/atoms/timezone";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { Timezone } from "@calcom/features/bookings/Booker/types";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { EventDetailBlocks } from "@calcom/features/bookings/types";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { EventDetails } from "./event-meta/Details";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const LoadingState = () => {
const { t } = useLocale();
return <span className="text-default text-sm">{t("loading")}</span>;
};
const WebTimezoneSelect = dynamic(
() =>
import("@calcom/features/components/timezone-select").then(
(mod) => mod.TimezoneSelect
),
{
ssr: false,
loading: () => <LoadingState />,
}
);
type SlotSelectionModalHeaderProps = {
onClick: () => void;
event?: Pick<
BookerEvent,
| "length"
| "metadata"
| "lockTimeZoneToggleOnBookingPage"
| "lockedTimeZone"
| "isDynamic"
| "currency"
| "price"
| "locations"
| "requiresConfirmation"
| "recurringEvent"
> | null;
isPlatform?: boolean;
timeZones?: Timezone[];
selectedDate: string | null;
};
export const SlotSelectionModalHeader = ({
onClick,
event,
isPlatform = false,
timeZones,
selectedDate,
}: SlotSelectionModalHeaderProps) => {
const { i18n } = useLocale();
const [setTimezone] = useTimePreferences((state) => [state.setTimezone]);
const [timezone, setBookerStoreTimezone] = useBookerStoreContext(
(state) => [state.timezone, state.setTimezone],
shallow
);
const [TimezoneSelect] = useMemo(
() => (isPlatform ? [PlatformTimezoneSelect] : [WebTimezoneSelect]),
[isPlatform]
);
const formattedDate = useMemo(() => {
if (!selectedDate) return { dayOfWeek: "", fullDate: "" };
const date = new Date(selectedDate);
const dayOfWeek = date.toLocaleDateString(i18n.language, {
weekday: "long",
});
const fullDate = date.toLocaleDateString(i18n.language, {
month: "long",
day: "numeric",
year: "numeric",
});
return { dayOfWeek, fullDate };
}, [selectedDate, i18n.language]);
return (
<div className="two-step-slot-selection-modal-header bg-default border-subtle sticky top-0 z-10 flex flex-col mb-4 mt-0 border-b pb-4 -mx-4 px-8">
<div className="flex flex-col gap-2 pt-8">
<div className="flex flex-col">
<span className="text-emphasis text-lg font-semibold">
<Button
color="minimal"
StartIcon="arrow-left"
className="mb-2 ml-[-42px] w-[40px]"
onClick={onClick}
/>{" "}
{formattedDate.dayOfWeek}
</span>
<span className="text-default text-sm">{formattedDate.fullDate}</span>
</div>
{event && (
<EventDetails event={event} blocks={[EventDetailBlocks.DURATION]} />
)}
<div className="text-default flex items-center gap-2 text-sm mb-0">
<Icon name="globe" className="text-subtle h-4 w-4 shrink-0" />
{TimezoneSelect && (
<span className="min-w-32 -mt-[2px] flex h-6 max-w-full items-center justify-start">
<TimezoneSelect
timeZones={timeZones}
menuPosition="fixed"
classNames={{
control: () =>
"min-h-0! p-0 w-full border-0 bg-transparent focus-within:ring-0 shadow-none!",
menu: () => "w-64! max-w-[90vw] mb-1",
singleValue: () => "text-text py-1",
indicatorsContainer: () => "ml-auto",
container: () => "max-w-full",
}}
value={
event?.lockTimeZoneToggleOnBookingPage
? event.lockedTimeZone || CURRENT_TIMEZONE
: timezone || CURRENT_TIMEZONE
}
onChange={({ value }) => {
setTimezone(value);
setBookerStoreTimezone(value);
}}
isDisabled={event?.lockTimeZoneToggleOnBookingPage}
/>
</span>
)}
</div>
</div>
</div>
);
};
+4 -1
View File
@@ -228,7 +228,9 @@ const ChooseEmbedTypesDialogContent = ({
return (
<DialogContent className="rounded-lg p-10" type="creation" size="lg">
<div className="mb-2">
<h3 className="font-cal text-emphasis mb-2 text-2xl font-semibold leading-none" id="modal-title">
<h3
className="font-cal text-emphasis mb-2 text-2xl font-semibold leading-none"
id="modal-title">
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
</h3>
<div>
@@ -864,6 +866,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
useState(true);
const defaultConfig = {
layout: BookerLayouts.MONTH_VIEW,
useSlotsViewOnSmallScreen: "true" as const,
};
const paletteDefaultValue = (paletteName: string) => {
+14
View File
@@ -7,6 +7,20 @@ global.React = React;
global.ResizeObserver = ResizeObserver;
expect.extend(matchers);
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
afterEach(() => {
vi.resetAllMocks();
});
+4 -2
View File
@@ -120,6 +120,7 @@ Cal.modal({
calLink: "organization/event-type",
config: {
// Optional configuration
useSlotsViewOnSmallScreen: "true"
}
});
```
@@ -144,7 +145,8 @@ Cal.inline({
config: {
name: "John Doe",
email: "john@example.com",
notes: "Initial discussion"
notes: "Initial discussion",
useSlotsViewOnSmallScreen: "true"
}
});
```
@@ -237,4 +239,4 @@ Cal.ns.myNamespace('prerender', {
Using the prerendered iframe with a CTA:
```js
<button data-cal-namespace="myNamespace" data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
```
```
+38 -7
View File
@@ -49,16 +49,16 @@
// Put the snippet in a function so that it can be re-executed for scenario testing
// TODO: How to reuse embed-snippet export here?
function embedSnippet() {
(function (C, A, L) {
let p = function (a, ar) {
((C, A, L) => {
const p = (a, ar) => {
a.q.push(ar);
};
let d = C.document;
const d = C.document;
C.Cal =
C.Cal ||
function () {
let cal = C.Cal;
let ar = arguments;
const cal = C.Cal;
const ar = arguments;
if (!cal.loaded) {
cal.ns = {};
cal.q = cal.q || [];
@@ -484,6 +484,14 @@
</div>
</div>
<div id="cal-booking-place-twoStepSlotSelection">
<div class="inline-embed-container" style="width: 30%;">
<h3><a href="?only=ns:twoStepSlotSelection">Test Two Step Slot Selection</a></h3>
<i>Note: Two step slot selection only applies to mobile view. If you're testing this on a big screen, you need to make the screen mobile width.</i>
<div class="place" style="width: 100%"></div>
</div>
</div>
<div id="cal-booking-place-skeletonDemo">
<h3><a href="?only=ns:skeletonDemo">Skeleton Loader INLINE Demo</a></h3>
<div class="inline-embed-container">
@@ -505,10 +513,33 @@
</div>
<script type="module" src="./playground/lib/playground.ts"></script>
<script>
if (initialNamespace.startsWith("skeletonDemo")) {
if (initialNamespace.startsWith("skeletonDemo") || initialNamespace === "twoStepSlotSelection") {
document.getElementById("misc-embeds").style.display = "none";
}
// For twoStepSlotSelection, make the .place div fill the entire viewport
// This ensures nothing else can intercept pointer events on mobile viewport
if (initialNamespace === "twoStepSlotSelection") {
const container = document.getElementById("cal-booking-place-twoStepSlotSelection");
if (container) {
// Hide the container's other content (h3, note)
container.style.display = "contents";
const inlineContainer = container.querySelector(".inline-embed-container");
if (inlineContainer) {
inlineContainer.style.display = "contents";
}
const place = container.querySelector(".place");
if (place) {
// Make the .place div fill the entire viewport
place.style.position = "fixed";
place.style.top = "0";
place.style.left = "0";
place.style.width = "100vw";
place.style.height = "100vh";
place.style.zIndex = "9999";
}
}
}
</script>
</body>
</html>
</html>
@@ -1,11 +1,11 @@
import type { GlobalCal, EmbedEvent } from "../../src/embed";
import type { EmbedEvent, GlobalCal } from "../../src/embed";
const Cal = window.Cal as GlobalCal;
Cal.config = Cal.config || {};
Cal.config.forwardQueryParams = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callback = function (e: any) {
const callback = (e: any) => {
const detail = e.detail;
};
@@ -45,21 +45,17 @@ if (themeInParam && !theme) {
const calLink = searchParams.get("cal-link");
function fakeEvent({
namespace,
eventType,
data
}) {
function fakeEvent({ namespace, eventType, data }) {
window.postMessage({
fullType: `CAL:${namespace}:${eventType}`,
namespace,
originator: "CAL",
type: eventType,
data,
})
});
}
window.heavilyCustomizeUi = function ({ namespace }) {
window.heavilyCustomizeUi = ({ namespace }) => {
Cal.ns[namespace]("ui", {
theme: "light",
cssVarsPerTheme: {
@@ -94,11 +90,11 @@ window.heavilyCustomizeUi = function ({ namespace }) {
});
};
window.fakeErrorScenario = function ({ namespace }) {
window.fakeErrorScenario = ({ namespace }) => {
fakeEvent({
namespace,
eventType: "linkFailed",
data: { code: 500 }
data: { code: 500 },
});
};
@@ -137,7 +133,7 @@ if (only === "all" || only === "ns:second") {
Cal.ns.second(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-second .place",
calLink: "pro?case=2",
@@ -166,7 +162,7 @@ if (only === "all" || only === "ns:third") {
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-third .place",
calLink: "pro/30min",
@@ -217,7 +213,7 @@ if (only === "all" || only === "ns:fourth") {
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-fourth .place",
calLink: "team/seeded-team",
@@ -262,7 +258,7 @@ if (only === "all" || only === "ns:corpTest") {
Cal.ns.corpTest([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-corpTest .place",
calLink: "pro",
@@ -283,7 +279,7 @@ if (only === "all" || only === "ns:fifth") {
Cal.ns.fifth([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-fifth .place",
calLink: "team/seeded-team/collective-seeded-team-event",
@@ -330,7 +326,7 @@ if (only === "all" || only === "inline-routing-form") {
Cal.ns["inline-routing-form"]([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-inline-routing-form .place",
calLink: "forms/948ae412-d995-4865-875a-48302588de03",
@@ -355,7 +351,7 @@ if (only === "all" || only === "ns:hideEventTypeDetails") {
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: `#cal-booking-place-${identifier} .place`,
calLink: "free/30min",
@@ -528,7 +524,7 @@ if (only === "all" || only == "ns:monthView") {
Cal.ns.monthView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-monthView .place",
calLink: "free/30min",
@@ -553,7 +549,7 @@ if (only === "all" || only == "ns:weekView") {
Cal.ns.weekView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-weekView .place",
calLink: "free/30min",
@@ -582,7 +578,7 @@ if (only === "all" || only == "ns:columnView") {
Cal.ns.columnView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-columnView .place",
calLink: "free/30min",
@@ -602,6 +598,39 @@ if (only === "all" || only == "ns:columnView") {
});
}
if (only === "all" || only == "ns:twoStepSlotSelection") {
Cal("init", "twoStepSlotSelection", {
debug: true,
origin: origin,
});
Cal.ns.twoStepSlotSelection(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-twoStepSlotSelection .place",
calLink: "free/30min",
config: {
iframeAttrs: {
id: "cal-booking-place-twoStepSlotSelection-iframe",
},
"flag.coep": "true",
name: "John",
email: "john@booker.com",
notes: ["test"],
guests: ["guest@example.com"],
useSlotsViewOnSmallScreen: "true",
},
}
);
Cal.ns.twoStepSlotSelection("on", {
action: "*",
callback,
});
}
if (only === "all" || only == "ns:columnViewHideEventTypeDetails") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "columnViewHideEventTypeDetails", {
@@ -612,7 +641,7 @@ if (only === "all" || only == "ns:columnViewHideEventTypeDetails") {
Cal.ns.columnViewHideEventTypeDetails(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
//@ts-expect-error
{
elementOrSelector: "#cal-booking-place-columnViewHideEventTypeDetails .place",
calLink: "free/30min",
@@ -127,4 +127,49 @@ test.describe("Embed Pages", () => {
expect(embedTheme).toBe(theme);
});
});
test.describe("useSlotsViewOnSmallScreen", () => {
test("should enable slots view on small screen when parameter is 'true'", async ({
page,
}) => {
await page.evaluate(() => {
window.name = "cal-embed=";
});
await page.goto("http://localhost:3000/free/30min/embed?useSlotsViewOnSmallScreen=true");
const useSlotsViewOnSmallScreen = await page.evaluate(() => {
return window.CalEmbed?.embedStore?.uiConfig?.useSlotsViewOnSmallScreen;
});
expect(useSlotsViewOnSmallScreen).toBe(true);
});
test("should default to false when parameter is not present", async ({
page,
}) => {
await page.evaluate(() => {
window.name = "cal-embed=";
});
await page.goto("http://localhost:3000/free/30min/embed");
const useSlotsViewOnSmallScreen = await page.evaluate(() => {
return window.CalEmbed?.embedStore?.uiConfig?.useSlotsViewOnSmallScreen;
});
expect(useSlotsViewOnSmallScreen).toBe(false);
});
test("should be false when parameter is 'false'", async ({ page }) => {
await page.evaluate(() => {
window.name = "cal-embed=";
});
await page.goto("http://localhost:3000/free/30min/embed?useSlotsViewOnSmallScreen=false");
const useSlotsViewOnSmallScreen = await page.evaluate(() => {
return window.CalEmbed?.embedStore?.uiConfig?.useSlotsViewOnSmallScreen;
});
expect(useSlotsViewOnSmallScreen).toBe(false);
});
});
});
@@ -0,0 +1,186 @@
import { test } from "@calcom/web/playwright/lib/fixtures";
import { expect } from "@playwright/test";
import { deleteAllBookingsByEmail, ensureEmbedIframe, getBooking } from "../lib/testUtils";
// Mobile viewport dimensions
const MOBILE_VIEWPORT = { width: 375, height: 667 };
test.describe("Two Step Slot Selection", () => {
test.describe.configure({ mode: "serial" });
test.afterEach(async () => {
await deleteAllBookingsByEmail("john@booker.com");
});
test("should open slot selection modal on mobile when date is clicked", async ({ page, embeds }) => {
// Set mobile viewport
await page.setViewportSize(MOBILE_VIEWPORT);
const calNamespace = "twoStepSlotSelection";
await embeds.gotoPlayground({ calNamespace, url: "/?only=ns:twoStepSlotSelection" });
const embedIframe = await ensureEmbedIframe({ calNamespace, page, pathname: "/free/30min" });
// Wait for the booker to be ready
await embedIframe.waitForSelector('[data-testid="day"]');
// Click on an available date
await embedIframe.locator('[data-testid="day"][data-disabled="false"]').first().click();
// Verify the slot selection modal opens (it has the two-step-slot-selection-modal-header class)
await expect(embedIframe.locator(".two-step-slot-selection-modal-header")).toBeVisible();
// Verify time slots are visible in the modal
await expect(embedIframe.locator('[data-testid="time"]').first()).toBeVisible();
});
test("should complete booking with prefilled form data (skipConfirmStep) in two-step modal", async ({
page,
embeds,
}) => {
// Set mobile viewport
await page.setViewportSize(MOBILE_VIEWPORT);
const calNamespace = "twoStepSlotSelection";
await embeds.gotoPlayground({ calNamespace, url: "/?only=ns:twoStepSlotSelection" });
const embedIframe = await ensureEmbedIframe({ calNamespace, page, pathname: "/free/30min" });
// Wait for the booker to be ready
await embedIframe.waitForSelector('[data-testid="day"]');
// Go to next month to ensure availability
await embedIframe.click('[data-testid="incrementMonth"]');
await embedIframe.waitForTimeout(1000);
// Click on an available date
await embedIframe.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
// Wait for modal to open
await expect(embedIframe.locator(".two-step-slot-selection-modal-header")).toBeVisible();
// Wait for time slots to be visible in the modal
await embedIframe.waitForSelector('[data-testid="time"]');
// Wait for the async skipConfirmStep validation to complete
// The useSkipConfirmStep hook runs an async schema validation when bookerState becomes "selecting_time"
// We need to give it time to validate the prefilled form data
await embedIframe.waitForTimeout(1000);
// Click on a time slot - this should show the confirm button since form is prefilled
await embedIframe.locator('[data-testid="time"]').first().click();
// Wait for confirm button to appear (skip-confirm-book-button appears when skipConfirmStep is true)
await expect(embedIframe.locator('[data-testid="skip-confirm-book-button"]')).toBeVisible();
// Set up response listener before clicking
const responsePromise = page.waitForResponse("**/api/book/event");
// Click the confirm button
await embedIframe.locator('[data-testid="skip-confirm-book-button"]').click();
// Verify the button shows loading state (modal should stay open while loading)
// The modal should remain visible during booking
await expect(embedIframe.locator(".two-step-slot-selection-modal-header")).toBeVisible();
// Wait for booking response
const response = await responsePromise;
expect(response.status()).toBe(200);
const booking = (await response.json()) as { uid: string };
// Verify the booking was created with booker and prefilled guest
const bookingFromDb = await getBooking(booking.uid);
expect(bookingFromDb.attendees.length).toBe(2);
const attendeeEmails = bookingFromDb.attendees.map((a) => a.email);
expect(attendeeEmails).toContain("john@booker.com");
expect(attendeeEmails).toContain("guest@example.com");
});
test("should show confirm button next to slot when form is prefilled", async ({ page, embeds }) => {
// Set mobile viewport
await page.setViewportSize(MOBILE_VIEWPORT);
const calNamespace = "twoStepSlotSelection";
await embeds.gotoPlayground({ calNamespace, url: "/?only=ns:twoStepSlotSelection" });
const embedIframe = await ensureEmbedIframe({ calNamespace, page, pathname: "/free/30min" });
// Wait for the booker to be ready
await embedIframe.waitForSelector('[data-testid="day"]');
// Go to next month
await embedIframe.click('[data-testid="incrementMonth"]');
await embedIframe.waitForTimeout(1000);
// Click on an available date
await embedIframe.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
// Wait for modal to open
await expect(embedIframe.locator(".two-step-slot-selection-modal-header")).toBeVisible();
// Click on a time slot
await embedIframe.locator('[data-testid="time"]').first().click();
// Verify confirm button appears next to the slot
const confirmButton = embedIframe.locator('[data-testid="skip-confirm-book-button"]');
await expect(confirmButton).toBeVisible();
// Verify button text is "Confirm" or "Pay and Book"
const buttonText = await confirmButton.textContent();
expect(buttonText?.toLowerCase()).toMatch(/confirm|pay/);
});
test("should close modal when back button is clicked", async ({ page, embeds }) => {
// Set mobile viewport
await page.setViewportSize(MOBILE_VIEWPORT);
const calNamespace = "twoStepSlotSelection";
await embeds.gotoPlayground({ calNamespace, url: "/?only=ns:twoStepSlotSelection" });
const embedIframe = await ensureEmbedIframe({ calNamespace, page, pathname: "/free/30min" });
// Wait for the booker to be ready
await embedIframe.waitForSelector('[data-testid="day"]');
// Click on an available date
await embedIframe.locator('[data-testid="day"][data-disabled="false"]').first().click();
// Verify modal opens
await expect(embedIframe.locator(".two-step-slot-selection-modal-header")).toBeVisible();
// Click the back button in the modal header
await embedIframe.locator(".two-step-slot-selection-modal-header button").first().click();
// Verify modal closes
await expect(embedIframe.locator(".two-step-slot-selection-modal-header")).not.toBeVisible();
});
test("should NOT open modal on desktop even with useSlotsViewOnSmallScreen enabled", async ({
page,
embeds,
}) => {
// Use desktop viewport (default is usually larger than 768px)
await page.setViewportSize({ width: 1280, height: 720 });
const calNamespace = "twoStepSlotSelection";
await embeds.gotoPlayground({ calNamespace, url: "/?only=ns:twoStepSlotSelection" });
const embedIframe = await ensureEmbedIframe({ calNamespace, page, pathname: "/free/30min" });
// Wait for the booker to be ready
await embedIframe.waitForSelector('[data-testid="day"]');
// Click on an available date
await embedIframe.locator('[data-testid="day"][data-disabled="false"]').first().click();
// Wait a bit to ensure modal would have opened if it was going to
await embedIframe.waitForTimeout(500);
// Verify the modal does NOT open on desktop
await expect(embedIframe.locator(".two-step-slot-selection-modal-header")).not.toBeVisible();
// Time slots should be visible in the regular view (not modal)
await expect(embedIframe.locator('[data-testid="time"]').first()).toBeVisible();
});
});
@@ -356,4 +356,63 @@ describe("embed-iframe", async () => {
expect(embedStore.viewId).toBe(3);
});
});
describe("useSlotsViewOnSmallScreen parameter parsing", () => {
let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore;
beforeEach(async () => {
// Reset modules to ensure fresh import
vi.resetModules();
({ embedStore } = await import("../embed-iframe/lib/embedStore"));
vi.useRealTimers();
// Mock window properties needed by main()
const mockTop = {};
Object.defineProperty(window, "top", {
value: mockTop,
writable: true,
configurable: true,
});
Object.defineProperty(window, "isEmbed", {
value: () => true,
writable: true,
configurable: true,
});
window.getEmbedNamespace = vi.fn(() => "default");
window.getEmbedTheme = vi.fn(() => null);
});
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("should default to false when parameter is not present", async () => {
fakeCurrentDocumentUrl(); // No useSlotsViewOnSmallScreen param
await import("../embed-iframe"); // This triggers main()
expect(embedStore.uiConfig?.useSlotsViewOnSmallScreen).toBe(false);
});
it("should be true when parameter is 'true'", async () => {
fakeCurrentDocumentUrl({ params: { useSlotsViewOnSmallScreen: "true" } });
await import("../embed-iframe"); // This triggers main()
expect(embedStore.uiConfig?.useSlotsViewOnSmallScreen).toBe(true);
});
it("should be false when parameter is 'false'", async () => {
fakeCurrentDocumentUrl({ params: { useSlotsViewOnSmallScreen: "false" } });
await import("../embed-iframe"); // This triggers main()
expect(embedStore.uiConfig?.useSlotsViewOnSmallScreen).toBe(false);
});
it("should be false when parameter has any other value", async () => {
fakeCurrentDocumentUrl({ params: { useSlotsViewOnSmallScreen: "invalid" } });
await import("../embed-iframe"); // This triggers main()
expect(embedStore.uiConfig?.useSlotsViewOnSmallScreen).toBe(false);
});
});
});
+93 -40
View File
@@ -3,7 +3,13 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { mapOldToNewCssVars } from "./ui/cssVarsMap";
import type { Message } from "./embed";
import { embedStore, EMBED_IFRAME_STATE, resetPageData, setReloadInitiated, incrementView } from "./embed-iframe/lib/embedStore";
import {
embedStore,
EMBED_IFRAME_STATE,
resetPageData,
setReloadInitiated,
incrementView,
} from "./embed-iframe/lib/embedStore";
import {
runAsap,
isBookerReady,
@@ -72,7 +78,9 @@ if (isBrowser) {
const setEmbedStyles = (stylesConfig: EmbedStyles) => {
embedStore.styles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
for (const [, setEmbedStyle] of Object.entries(
embedStore.reactStylesStateSetters
)) {
setEmbedStyle((styles) => {
return {
...styles,
@@ -84,7 +92,9 @@ const setEmbedStyles = (stylesConfig: EmbedStyles) => {
const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
embedStore.nonStyles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
for (const [, setEmbedStyle] of Object.entries(
embedStore.reactStylesStateSetters
)) {
setEmbedStyle((styles) => {
return {
...styles,
@@ -97,27 +107,30 @@ const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
const registerNewSetter = (
registration:
| {
elementName: keyof EmbedStyles;
setState: SetStyles;
styles: true;
}
elementName: keyof EmbedStyles;
setState: SetStyles;
styles: true;
}
| {
elementName: keyof EmbedNonStylesConfig;
setState: setNonStylesConfig;
styles: false;
}
elementName: keyof EmbedNonStylesConfig;
setState: setNonStylesConfig;
styles: false;
}
) => {
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
// So, we should call the setter immediately with available embedStyles
if (registration.styles) {
embedStore.reactStylesStateSetters[registration.elementName as keyof EmbedStyles] = registration.setState;
embedStore.reactStylesStateSetters[
registration.elementName as keyof EmbedStyles
] = registration.setState;
registration.setState(embedStore.styles || {});
return () => {
delete embedStore.reactStylesStateSetters[registration.elementName];
};
} else {
embedStore.reactNonStylesStateSetters[registration.elementName as keyof EmbedNonStylesConfig] =
registration.setState;
embedStore.reactNonStylesStateSetters[
registration.elementName as keyof EmbedNonStylesConfig
] = registration.setState;
registration.setState(embedStore.nonStyles || {});
return () => {
@@ -173,7 +186,9 @@ export const useEmbedUiConfig = () => {
embedStore.setUiConfig.push(setUiConfig);
useEffect(() => {
return () => {
const foundAtIndex = embedStore.setUiConfig.findIndex((item) => item === setUiConfig);
const foundAtIndex = embedStore.setUiConfig.findIndex(
(item) => item === setUiConfig
);
// Keep removing the setters that are stale
embedStore.setUiConfig.splice(foundAtIndex, 1);
};
@@ -186,18 +201,28 @@ export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
const [, setStyles] = useState<EmbedStyles>({});
useEffect(() => {
return registerNewSetter({ elementName, setState: setStyles, styles: true });
return registerNewSetter({
elementName,
setState: setStyles,
styles: true,
});
}, []);
const styles = embedStore.styles || {};
// Always read the data from global embedStore so that even across components, the same data is used.
return styles[elementName] || {};
};
export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => {
export const useEmbedNonStylesConfig = (
elementName: keyof EmbedNonStylesConfig
) => {
const [, setNonStyles] = useState({} as EmbedNonStylesConfig);
useEffect(() => {
return registerNewSetter({ elementName, setState: setNonStyles, styles: false });
return registerNewSetter({
elementName,
setState: setNonStyles,
styles: false,
});
}, []);
// Always read the data from global embedStore so that even across components, the same data is used.
@@ -218,7 +243,9 @@ export const useIsBackgroundTransparent = () => {
export const useBrandColors = () => {
// TODO: Branding shouldn't be part of ui.styles. It should exist as ui.branding.
const brandingColors = useEmbedNonStylesConfig("branding") as EmbedNonStylesConfig["branding"];
const brandingColors = useEmbedNonStylesConfig(
"branding"
) as EmbedNonStylesConfig["branding"];
return brandingColors || {};
};
@@ -240,7 +267,8 @@ function getEmbedType() {
}
if (isBrowser) {
const url = new URL(document.URL);
const embedType = (embedStore.embedType = url.searchParams.get("embedType"));
const embedType = (embedStore.embedType =
url.searchParams.get("embedType"));
return embedType;
}
}
@@ -314,13 +342,14 @@ async function ensureRoutingFormResponseIdInUrl({
// Update routingFormResponseId in url only after connect is completed, to keep things simple
// Adding cal.routingFormResponseId in query param later shouldn't change anything in UI plus no slot request would go again due ot this.
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: {
...toBeThereParams,
"cal.routingFormResponseId": newlyRecordedResponseId.toString(),
},
toRemoveParams,
});
const { stopEnsuringQueryParamsInUrl } =
embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: {
...toBeThereParams,
"cal.routingFormResponseId": newlyRecordedResponseId.toString(),
},
toRemoveParams,
});
// Immediately stop ensuring query params in url as the page is already ready
// We could think about doing it after some time if needed later.
stopEnsuringQueryParamsInUrl();
@@ -338,7 +367,6 @@ async function waitForRenderStateToBeCompleted() {
});
}
// It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
export const methods = {
ui: function style(uiConfig: UiConfig) {
@@ -371,7 +399,10 @@ export const methods = {
let mergedCssVarsPerTheme: UiConfig["cssVarsPerTheme"] | undefined;
if (oldCssVarsPerTheme || newCssVarsPerTheme) {
mergedCssVarsPerTheme = {} as Record<"light" | "dark", Record<string, string>>;
mergedCssVarsPerTheme = {} as Record<
"light" | "dark",
Record<string, string>
>;
const themeKeys = [
...(oldCssVarsPerTheme ? Object.keys(oldCssVarsPerTheme) : []),
...(newCssVarsPerTheme ? Object.keys(newCssVarsPerTheme) : []),
@@ -389,11 +420,15 @@ export const methods = {
uiConfig = {
...embedStore.uiConfig,
...uiConfig,
...(mergedCssVarsPerTheme ? { cssVarsPerTheme: mergedCssVarsPerTheme } : {}),
...(mergedCssVarsPerTheme
? { cssVarsPerTheme: mergedCssVarsPerTheme }
: {}),
};
if (uiConfig.cssVarsPerTheme) {
const mappedCssVarsPerTheme = mapOldToNewCssVars(uiConfig.cssVarsPerTheme);
const mappedCssVarsPerTheme = mapOldToNewCssVars(
uiConfig.cssVarsPerTheme
);
window.CalEmbed.applyCssVars(mappedCssVarsPerTheme);
}
@@ -458,7 +493,9 @@ export const methods = {
embedStore.providedCorrectHeightToParent = false;
if (noSlotsFetchOnConnect !== "true") {
log("Method: connect, noSlotsFetchOnConnect is false. Requesting slots re-fetch");
log(
"Method: connect, noSlotsFetchOnConnect is false. Requesting slots re-fetch"
);
// Incrementing the version forces the slots call to be made again
embedStore.connectVersion = embedStore.connectVersion + 1;
}
@@ -503,7 +540,9 @@ export const methods = {
};
export type InterfaceWithParent = {
[key in keyof typeof methods]: (firstAndOnlyArg: Parameters<(typeof methods)[key]>[number]) => void;
[key in keyof typeof methods]: (
firstAndOnlyArg: Parameters<(typeof methods)[key]>[number]
) => void;
};
export const interfaceWithParent: InterfaceWithParent = methods;
@@ -528,11 +567,18 @@ function main() {
const autoScrollFromParam = url.searchParams.get("ui.autoscroll");
const shouldDisableAutoScroll = autoScrollFromParam === "false";
const useSlotsViewOnSmallScreenParam = url.searchParams.get(
"useSlotsViewOnSmallScreen"
);
embedStore.uiConfig = {
// TODO: Add theme as well here
colorScheme: url.searchParams.get("ui.color-scheme"),
layout: url.searchParams.get("layout") as BookerLayouts,
disableAutoScroll: shouldDisableAutoScroll,
// by default useSlotsViewOnSmallScreen should be false
useSlotsViewOnSmallScreen:
(useSlotsViewOnSmallScreenParam ?? "false") === "true",
};
actOnColorScheme(embedStore.uiConfig.colorScheme);
@@ -544,7 +590,8 @@ function main() {
return;
}
const willSlotsBeFetched = url.searchParams.get("cal.skipSlotsFetch") !== "true";
const willSlotsBeFetched =
url.searchParams.get("cal.skipSlotsFetch") !== "true";
log(`Slots will ${willSlotsBeFetched ? "" : "NOT "}be fetched`);
window.addEventListener("message", (e) => {
@@ -582,7 +629,10 @@ function main() {
});
sdkActionManager?.on("*", (e) => {
if (isPrerendering() && !eventsAllowedInPrerendering.includes(e.detail.type)) {
if (
isPrerendering() &&
!eventsAllowedInPrerendering.includes(e.detail.type)
) {
return;
}
const detail = e.detail;
@@ -593,7 +643,9 @@ function main() {
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
initializeAndSetupEmbed();
} else {
log(`Preloaded scenario - Skipping initialization and setup as only assets need to be loaded`);
log(
`Preloaded scenario - Skipping initialization and setup as only assets need to be loaded`
);
}
}
@@ -670,10 +722,11 @@ async function connectPreloadedEmbed({
toBeThereParams: Record<string, string | string[]>;
toRemoveParams: string[];
}) {
const { hasChanged, stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams,
toRemoveParams,
});
const { hasChanged, stopEnsuringQueryParamsInUrl } =
embedStore.router.ensureQueryParamsInUrl({
toBeThereParams,
toRemoveParams,
});
let waitForFrames = 0;
+3
View File
@@ -33,6 +33,7 @@ export type UiConfig = {
layout?: BookerLayouts;
colorScheme?: string | null;
disableAutoScroll?: boolean;
useSlotsViewOnSmallScreen?: boolean;
};
declare global {
@@ -71,6 +72,8 @@ export type KnownConfig = {
"cal.embed.pageType"?: EmbedPageType;
// If true, then when doing a "connect" with pre-rendered iframe, we won't fetch slots. This is useful when we are reusing the iframe fully as is.
"cal.embed.noSlotsFetchOnConnect"?: "true" | "false";
// If true, enables slots view on small screen in the booker
useSlotsViewOnSmallScreen?: "true" | "false";
};
export type EmbedBookerState =
@@ -15,14 +15,18 @@ function App() {
embedJsUrl: "http://localhost:3000/embed/embed.js",
namespace: calNamespace,
});
cal("ui", { styles: { branding: { brandColor: "#000000" } }, hideEventTypeDetails: false });
cal("ui", {
styles: { branding: { brandColor: "#000000" } },
hideEventTypeDetails: false,
});
})();
}, []);
return (
<button
data-cal-namespace={calNamespace}
data-cal-link="pro"
data-cal-config='{"layout":"month_view", "theme":"dark"}'>
data-cal-config='{"layout":"month_view", "theme":"dark", "useSlotsViewOnSmallScreen":"true"}'
>
Click me
</button>
);
+4 -1
View File
@@ -69,7 +69,9 @@ function App() {
});
// Also, validates the type of e.detail.data as TS runs on this file
const bookingSuccessfulV2Callback = (e: EmbedEvent<"bookingSuccessfulV2">) => {
const bookingSuccessfulV2Callback = (
e: EmbedEvent<"bookingSuccessfulV2">
) => {
const data = e.detail.data;
console.log("bookingSuccessfulV2", {
endTime: data.endTime,
@@ -109,6 +111,7 @@ function App() {
guests: ["janedoe@gmail.com"],
theme: "dark",
"cal.embed.pageType": "user.event.booking.slots",
useSlotsViewOnSmallScreen: "true",
}}
/>
</>
@@ -1,12 +1,10 @@
import { render } from "@testing-library/react";
import type { RenderOptions } from "@testing-library/react";
import type React from "react";
import type { ReactElement } from "react";
import { vi } from "vitest";
import type { StoreApi } from "zustand";
import dayjs from "@calcom/dayjs";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import type { RenderOptions } from "@testing-library/react";
import { render } from "@testing-library/react";
import React, { type ReactElement, type ReactNode } from "react";
import { vi } from "vitest";
import type { StoreApi } from "zustand";
import { BookerStoreContext } from "../BookerStoreProvider";
import type { BookerStore } from "../store";
@@ -72,6 +70,8 @@ const createMockStore = (initialState?: Partial<BookerStore>): StoreApi<BookerSt
allowUpdatingUrlParams: true,
verificationCode: null,
setVerificationCode: vi.fn(),
isSlotSelectionModalVisible: false,
setIsSlotSelectionModalVisible: vi.fn(),
...initialState,
};
@@ -100,7 +100,7 @@ export const renderWithBookerStore = (
): ReturnType<typeof render> => {
const mockStore = createMockStore(options?.mockStore);
const Wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => (
const Wrapper = ({ children }: { children: ReactNode }): ReactElement => (
<BookerStoreContext.Provider value={mockStore}>{children}</BookerStoreContext.Provider>
);
@@ -1,12 +1,17 @@
import { useEffect, useRef } from "react";
import { shallow } from "zustand/shallow";
import { useEmbedType, useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import {
useEmbedType,
useEmbedUiConfig,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
import { defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
import { useSlotsViewOnSmallScreen } from "@calcom/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen";
import { extraDaysConfig } from "../../config";
import type { BookerLayout } from "../../types";
@@ -16,17 +21,29 @@ import { getQueryParam } from "../../utils/query-param";
export type UseBookerLayoutType = ReturnType<typeof useBookerLayout>;
export const useBookerLayout = (
profileBookerLayouts: BookerEvent["profile"]["bookerLayouts"] | undefined | null
profileBookerLayouts:
| BookerEvent["profile"]["bookerLayouts"]
| undefined
| null
) => {
const [_layout, setLayout] = useBookerStoreContext((state) => [state.layout, state.setLayout], shallow);
const [_layout, setLayout] = useBookerStoreContext(
(state) => [state.layout, state.setLayout],
shallow
);
const isEmbed = useIsEmbed();
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
const embedUiConfig = useEmbedUiConfig();
// 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 extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop;
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";
@@ -55,7 +72,9 @@ export const useBookerLayout = (
bookerLayouts?.enabledLayouts?.length &&
layout !== _layout
) {
const validLayout = bookerLayouts.enabledLayouts.find((userLayout) => userLayout === layout);
const validLayout = bookerLayouts.enabledLayouts.find(
(userLayout) => userLayout === layout
);
if (validLayout) setLayout(validLayout);
}
}, [bookerLayouts, setLayout, _layout, isEmbed, isMobile]);
@@ -83,6 +102,8 @@ export const useBookerLayout = (
? hideEventTypeDetailsParam === "true"
: false;
const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen();
return {
shouldShowFormInDialog,
hasDarkBackground,
@@ -94,6 +115,7 @@ export const useBookerLayout = (
layout,
defaultLayout,
hideEventTypeDetails,
slotsViewOnSmallScreen,
bookerLayouts,
};
};
@@ -2,13 +2,24 @@ import { useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useSlotsViewOnSmallScreen } from "@calcom/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen";
export const useInitializeWeekStart = (isPlatform: boolean, isCalendarView: boolean) => {
export const useInitializeWeekStart = (
isPlatform: boolean,
isCalendarView: boolean
) => {
const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen();
const today = dayjs();
const weekStart = today.startOf("week").format("YYYY-MM-DD");
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
const setSelectedDate = useBookerStoreContext(
(state) => state.setSelectedDate
);
useEffect(() => {
// don't auto-select date if slots view on small screen is enabled
// auto-selecting would open the slots modal in that case which the user would most probably have to close
if (slotsViewOnSmallScreen) return;
if (isPlatform && isCalendarView) {
setSelectedDate({ date: weekStart, omitUpdatingParams: true });
}
@@ -1,9 +1,7 @@
import { useState, useEffect } from "react";
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { getBookingResponsesSchemaWithOptionalChecks } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { useEffect, useState } from "react";
import type { BookerEvent } from "../../../types";
import type { BookerState } from "../../types";
@@ -0,0 +1,13 @@
import { useIsEmbed, useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
export const useSlotsViewOnSmallScreen = () => {
const isEmbed = useIsEmbed();
const isMobile = useMediaQuery("(max-width: 768px)");
const embedUiConfig = useEmbedUiConfig();
if (!isEmbed || !isMobile) return false;
return embedUiConfig.useSlotsViewOnSmallScreen ?? false;
};
+307 -43
View File
@@ -3,48 +3,270 @@
import { useEffect } from "react";
import { createWithEqualityFn } from "zustand/traditional";
import dayjs from "@calcom/dayjs";
import { BOOKER_NUMBER_OF_DAYS_TO_LOAD } from "@calcom/lib/constants";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import type { GetBookingType } from "../lib/get-booking";
import type { BookerState, BookerLayout } from "./types";
import { updateQueryParam, getQueryParam, removeQueryParam } from "./utils/query-param";
import {
updateQueryParam,
getQueryParam,
removeQueryParam,
} from "./utils/query-param";
const _iso_3166_1_alpha_2_codes = [
"ad", "ae", "af", "ag", "ai", "al", "am", "ao", "aq", "ar", "as", "at", "au", "aw", "ax", "az",
"ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bl", "bm", "bn", "bo", "bq", "br", "bs", "bt", "bv", "bw", "by", "bz",
"ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "cr", "cu", "cv", "cw", "cx", "cy", "cz",
"de", "dj", "dk", "dm", "do", "dz",
"ec", "ee", "eg", "eh", "er", "es", "et",
"fi", "fj", "fk", "fm", "fo", "fr",
"ga", "gb", "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy",
"hk", "hm", "hn", "hr", "ht", "hu",
"id", "ie", "il", "im", "in", "io", "iq", "ir", "is", "it",
"je", "jm", "jo", "jp",
"ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", "kz",
"la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly",
"ma", "mc", "md", "me", "mf", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz",
"na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz",
"ad",
"ae",
"af",
"ag",
"ai",
"al",
"am",
"ao",
"aq",
"ar",
"as",
"at",
"au",
"aw",
"ax",
"az",
"ba",
"bb",
"bd",
"be",
"bf",
"bg",
"bh",
"bi",
"bj",
"bl",
"bm",
"bn",
"bo",
"bq",
"br",
"bs",
"bt",
"bv",
"bw",
"by",
"bz",
"ca",
"cc",
"cd",
"cf",
"cg",
"ch",
"ci",
"ck",
"cl",
"cm",
"cn",
"co",
"cr",
"cu",
"cv",
"cw",
"cx",
"cy",
"cz",
"de",
"dj",
"dk",
"dm",
"do",
"dz",
"ec",
"ee",
"eg",
"eh",
"er",
"es",
"et",
"fi",
"fj",
"fk",
"fm",
"fo",
"fr",
"ga",
"gb",
"gd",
"ge",
"gf",
"gg",
"gh",
"gi",
"gl",
"gm",
"gn",
"gp",
"gq",
"gr",
"gs",
"gt",
"gu",
"gw",
"gy",
"hk",
"hm",
"hn",
"hr",
"ht",
"hu",
"id",
"ie",
"il",
"im",
"in",
"io",
"iq",
"ir",
"is",
"it",
"je",
"jm",
"jo",
"jp",
"ke",
"kg",
"kh",
"ki",
"km",
"kn",
"kp",
"kr",
"kw",
"ky",
"kz",
"la",
"lb",
"lc",
"li",
"lk",
"lr",
"ls",
"lt",
"lu",
"lv",
"ly",
"ma",
"mc",
"md",
"me",
"mf",
"mg",
"mh",
"mk",
"ml",
"mm",
"mn",
"mo",
"mp",
"mq",
"mr",
"ms",
"mt",
"mu",
"mv",
"mw",
"mx",
"my",
"mz",
"na",
"nc",
"ne",
"nf",
"ng",
"ni",
"nl",
"no",
"np",
"nr",
"nu",
"nz",
"om",
"pa", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py",
"pa",
"pe",
"pf",
"pg",
"ph",
"pk",
"pl",
"pm",
"pn",
"pr",
"ps",
"pt",
"pw",
"py",
"qa",
"re", "ro", "rs", "ru", "rw",
"sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sr", "ss", "st", "sv", "sx", "sy",
"tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tr", "tt", "tv", "tw", "tz",
"ua", "ug", "um", "us", "uy", "uz",
"va", "vc", "ve", "vg", "vi", "vn", "vu",
"wf", "ws",
"ye", "yt",
"za", "zm", "zw"
"re",
"ro",
"rs",
"ru",
"rw",
"sa",
"sb",
"sc",
"sd",
"se",
"sg",
"sh",
"si",
"sj",
"sk",
"sl",
"sm",
"sn",
"so",
"sr",
"ss",
"st",
"sv",
"sx",
"sy",
"tc",
"td",
"tf",
"tg",
"th",
"tj",
"tk",
"tl",
"tm",
"tn",
"to",
"tr",
"tt",
"tv",
"tw",
"tz",
"ua",
"ug",
"um",
"us",
"uy",
"uz",
"va",
"vc",
"ve",
"vg",
"vi",
"vn",
"vu",
"wf",
"ws",
"ye",
"yt",
"za",
"zm",
"zw",
] as const;
export type CountryCode = typeof _iso_3166_1_alpha_2_codes[number];
export type CountryCode = (typeof _iso_3166_1_alpha_2_codes)[number];
/**
* Arguments passed into store initializer, containing
@@ -135,7 +357,9 @@ export type BookerStore = {
* Multiple Selected Dates and Times
*/
selectedDatesAndTimes: { [key: string]: { [key: string]: string[] } } | null;
setSelectedDatesAndTimes: (selectedDatesAndTimes: { [key: string]: { [key: string]: string[] } }) => void;
setSelectedDatesAndTimes: (selectedDatesAndTimes: {
[key: string]: { [key: string]: string[] };
}) => void;
/**
* Multiple duration configuration
*/
@@ -215,6 +439,11 @@ export type BookerStore = {
isPlatform?: boolean;
allowUpdatingUrlParams?: boolean;
defaultPhoneCountry?: CountryCode | null;
/**
* Whether the two-step slot selection modal/dialog is visible
*/
isSlotSelectionModalVisible: boolean;
setIsSlotSelectionModalVisible: (visible: boolean) => void;
};
/**
@@ -228,7 +457,10 @@ export const createBookerStore = () =>
setLayout: (layout: BookerLayout) => {
// If we switch to a large layout and don't have a date selected yet,
// we selected it here, so week title is rendered properly.
if (["week_view", "column_view"].includes(layout) && !get().selectedDate) {
if (
["week_view", "column_view"].includes(layout) &&
!get().selectedDate
) {
set({ selectedDate: dayjs().format("YYYY-MM-DD") });
}
if (!get().isPlatform || get().allowUpdatingUrlParams) {
@@ -237,7 +469,11 @@ export const createBookerStore = () =>
return set({ layout });
},
selectedDate: getQueryParam("date") || null,
setSelectedDate: ({ date: selectedDate, omitUpdatingParams = false, preventMonthSwitching = false }) => {
setSelectedDate: ({
date: selectedDate,
omitUpdatingParams = false,
preventMonthSwitching = false,
}) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
@@ -247,15 +483,24 @@ export const createBookerStore = () =>
const currentSelection = dayjs(get().selectedDate);
const newSelection = dayjs(selectedDate);
set({ selectedDate });
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
if (
!omitUpdatingParams &&
(!get().isPlatform || get().allowUpdatingUrlParams)
) {
updateQueryParam("date", selectedDate ?? "");
}
// Setting month make sure small calendar in fullscreen layouts also updates.
// preventMonthSwitching is true in monthly view
if (!preventMonthSwitching && newSelection.month() !== currentSelection.month()) {
if (
!preventMonthSwitching &&
newSelection.month() !== currentSelection.month()
) {
set({ month: newSelection.format("YYYY-MM") });
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
if (
!omitUpdatingParams &&
(!get().isPlatform || get().allowUpdatingUrlParams)
) {
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
}
@@ -316,7 +561,8 @@ export const createBookerStore = () =>
}
get().setSelectedDate({ date: null });
},
dayCount: BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
dayCount:
BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
setDayCount: (dayCount: number | null) => {
set({ dayCount });
},
@@ -398,7 +644,9 @@ export const createBookerStore = () =>
// Preselect today's date in week / column view, since they use this to show the week title.
selectedDate:
selectedDateInStore ||
(["week_view", "column_view"].includes(layout) ? dayjs().format("YYYY-MM-DD") : null),
(["week_view", "column_view"].includes(layout)
? dayjs().format("YYYY-MM-DD")
: null),
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
@@ -459,14 +707,24 @@ export const createBookerStore = () =>
set({ rescheduleUid });
},
recurringEventCount: null,
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }),
recurringEventCountQueryParam: Number(getQueryParam("recurringEventCount")) || null,
setRecurringEventCountQueryParam: (recurringEventCountQueryParam: number | null) => {
setRecurringEventCount: (recurringEventCount: number | null) =>
set({ recurringEventCount }),
recurringEventCountQueryParam:
Number(getQueryParam("recurringEventCount")) || null,
setRecurringEventCountQueryParam: (
recurringEventCountQueryParam: number | null
) => {
// Guard: only update state if value is valid (not NaN or null)
if (recurringEventCountQueryParam !== null && !isNaN(recurringEventCountQueryParam)) {
if (
recurringEventCountQueryParam !== null &&
!isNaN(recurringEventCountQueryParam)
) {
set({ recurringEventCountQueryParam });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("recurringEventCount", recurringEventCountQueryParam);
updateQueryParam(
"recurringEventCount",
recurringEventCountQueryParam
);
}
}
// If invalid, don't update state or URL - just ignore the call
@@ -496,6 +754,12 @@ export const createBookerStore = () =>
isPlatform: false,
allowUpdatingUrlParams: true,
defaultPhoneCountry: null,
isSlotSelectionModalVisible: false,
setIsSlotSelectionModalVisible: (
isSlotSelectionModalVisible: boolean
) => {
set({ isSlotSelectionModalVisible });
},
}));
/**
+117 -56
View File
@@ -17,6 +17,7 @@ import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip";
import NoAvailabilityDialog from "./NoAvailabilityDialog";
import { useSlotsViewOnSmallScreen } from "@calcom/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen";
export type DatePickerProps = {
/** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */
@@ -81,7 +82,11 @@ const Day = ({
const buttonContent = (
<button
type="button"
style={disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }}
style={
disabled
? { ...disabledDateButtonEmbedStyles }
: { ...enabledDateButtonEmbedStyles }
}
className={classNames(
"disabled:text-bookinglighter absolute bottom-0 left-0 right-0 top-0 mx-auto w-full rounded-md border-2 border-transparent text-center text-sm font-medium transition disabled:cursor-default disabled:border-transparent disabled:font-light ",
active
@@ -97,7 +102,8 @@ const Day = ({
data-testid="day"
data-disabled={disabled}
disabled={disabled}
{...props}>
{...props}
>
{away && <span data-testid="away-emoji">{emoji}</span>}
{!away && date.date()}
{date.isToday() && (
@@ -105,7 +111,8 @@ const Day = ({
className={classNames(
"bg-brand-default absolute left-1/2 top-1/2 flex h-[5px] w-[5px] -translate-x-1/2 translate-y-[8px] items-center justify-center rounded-full align-middle sm:translate-y-[12px]",
active && "bg-brand-accent"
)}>
)}
>
<span className="sr-only">{t("today")}</span>
</span>
)}
@@ -131,7 +138,8 @@ const Day = ({
fontSize: "10px",
lineHeight: "13px",
padding: disabled ? "0 3px" : "3px 3px 3px 4px",
}}>
}}
>
{date.format("MMM")}
</div>
)}
@@ -172,6 +180,10 @@ const Days = ({
periodData: PeriodData;
isCompact?: boolean;
}) => {
const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen();
const layout = useBookerStoreContext((state) => state.layout);
const isMobile = layout === "mobile";
const includedDates = getAvailableDatesInMonth({
browsingDate: browsingDate.toDate(),
minDate,
@@ -183,7 +195,8 @@ const Days = ({
const isSecondWeekOver = today.isAfter(firstDayOfMonth.add(2, "week"));
let days: (Dayjs | null)[] = [];
const getPadding = (day: number) => (browsingDate.set("date", day).day() - weekStart + 7) % 7;
const getPadding = (day: number) =>
(browsingDate.set("date", day).day() - weekStart + 7) % 7;
const totalDays = daysInMonth(browsingDate);
const showNextMonthDays = isSecondWeekOver && !isCompact;
@@ -216,12 +229,18 @@ const Days = ({
}
}
const [selectedDatesAndTimes] = useBookerStoreContext((state) => [state.selectedDatesAndTimes], shallow);
const [selectedDatesAndTimes] = useBookerStoreContext(
(state) => [state.selectedDatesAndTimes],
shallow
);
const isActive = (day: dayjs.Dayjs) => {
// for selecting a range of dates
if (Array.isArray(selected)) {
return Array.isArray(selected) && selected?.some((e) => yyyymmdd(e) === yyyymmdd(day));
return (
Array.isArray(selected) &&
selected?.some((e) => yyyymmdd(e) === yyyymmdd(day))
);
}
if (selected && yyyymmdd(selected) === yyyymmdd(day)) {
@@ -235,9 +254,11 @@ const Days = ({
selectedDatesAndTimes[eventSlug as string] &&
Object.keys(selectedDatesAndTimes[eventSlug as string]).length > 0
) {
return Object.keys(selectedDatesAndTimes[eventSlug as string]).some((date) => {
return yyyymmdd(dayjs(date)) === yyyymmdd(day);
});
return Object.keys(selectedDatesAndTimes[eventSlug as string]).some(
(date) => {
return yyyymmdd(dayjs(date)) === yyyymmdd(day);
}
);
}
return false;
@@ -251,18 +272,24 @@ const Days = ({
const oooInfo = daySlots.find((slot) => slot.away) || null;
const isNextMonth = day.month() !== browsingDate.month();
const isFirstDayOfNextMonth = isSecondWeekOver && !isCompact && isNextMonth && day.date() === 1;
const isFirstDayOfNextMonth =
isSecondWeekOver && !isCompact && isNextMonth && day.date() === 1;
const included = includedDates?.includes(dateKey);
const excluded = excludedDates.includes(dateKey);
const hasAvailableSlots = daySlots.some((slot) => !slot.away);
const isOOOAllDay = daySlots.length > 0 && daySlots.every((slot) => slot.away);
const isOOOAllDay =
daySlots.length > 0 && daySlots.every((slot) => slot.away);
const away = isOOOAllDay;
// OOO dates are selectable only if there's a redirect user OR the note is public
const oooIsSelectable = oooInfo?.toUser || oooInfo?.showNotePublicly;
const disabled = away ? !oooIsSelectable : isNextMonth ? !hasAvailableSlots : !included || excluded;
const disabled = away
? !oooIsSelectable
: isNextMonth
? !hasAvailableSlots
: !included || excluded;
return {
day,
@@ -278,18 +305,24 @@ const Days = ({
*/
const useHandleInitialDateSelection = () => {
// Don't auto-select date when slots view on small screen is enabled on mobile
if (slotsViewOnSmallScreen) {
return;
}
// Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment
if (selected instanceof Array) {
return;
}
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day;
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find(
(day) => !day.disabled
)?.day;
const isSelectedDateAvailable = selected
? daysToRenderForTheMonth.some(({ day, disabled }) => {
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true;
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled)
return true;
})
: false;
if (!isSelectedDateAvailable && firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month
const shouldOmitUpdatingParams = selected?.isValid() ? false : true; // In case a date is selected and it is not available, then we have to change search params
@@ -307,38 +340,48 @@ const Days = ({
return (
<>
{daysToRenderForTheMonth.map(({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => (
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
{day === null ? (
<div key={`e-${idx}`} />
) : props.isLoading ? (
<button
className="bg-cal-muted text-muted absolute bottom-0 left-0 right-0 top-0 mx-auto flex w-full items-center justify-center rounded-sm border-transparent text-center font-medium opacity-90 transition"
key={`e-${idx}`}
disabled>
<SkeletonText className="h-8 w-9" />
</button>
) : (
<DayComponent
customClassName={{
dayContainer: customClassName?.datePickerDate,
dayActive: customClassName?.datePickerDateActive,
}}
date={day}
onClick={() => {
props.onChange(day);
props?.scrollToTimeSlots?.();
}}
disabled={disabled}
active={isActive(day)}
away={away}
emoji={emoji}
showMonthTooltip={showNextMonthDays && !disabled && day.month() !== browsingDate.month()}
isFirstDayOfNextMonth={isFirstDayOfNextMonth}
/>
)}
</div>
))}
{daysToRenderForTheMonth.map(
({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => (
<div
key={day === null ? `e-${idx}` : `day-${day.format()}`}
className="relative w-full pt-[100%]"
>
{day === null ? (
<div key={`e-${idx}`} />
) : props.isLoading ? (
<button
className="bg-cal-muted text-muted absolute bottom-0 left-0 right-0 top-0 mx-auto flex w-full items-center justify-center rounded-sm border-transparent text-center font-medium opacity-90 transition"
key={`e-${idx}`}
disabled
>
<SkeletonText className="h-8 w-9" />
</button>
) : (
<DayComponent
customClassName={{
dayContainer: customClassName?.datePickerDate,
dayActive: customClassName?.datePickerDateActive,
}}
date={day}
onClick={() => {
props.onChange(day);
props?.scrollToTimeSlots?.();
}}
disabled={disabled}
active={isActive(day)}
away={away}
emoji={emoji}
showMonthTooltip={
showNextMonthDays &&
!disabled &&
day.month() !== browsingDate.month()
}
isFirstDayOfNextMonth={isFirstDayOfNextMonth}
/>
)}
</div>
)
)}
{!props.isLoading &&
!isBookingInPast &&
includedDates &&
@@ -386,13 +429,18 @@ const DatePicker = ({
scrollToTimeSlots?: () => void;
}) => {
const minDate = passThroughProps.minDate;
const rawBrowsingDate = passThroughProps.browsingDate || dayjs().startOf("month");
const rawBrowsingDate =
passThroughProps.browsingDate || dayjs().startOf("month");
const browsingDate =
minDate && rawBrowsingDate.valueOf() < minDate.valueOf() ? dayjs(minDate) : rawBrowsingDate;
minDate && rawBrowsingDate.valueOf() < minDate.valueOf()
? dayjs(minDate)
: rawBrowsingDate;
const { i18n, t } = useLocale();
const bookingData = useBookerStoreContext((state) => state.bookingData);
const isBookingInPast = bookingData ? new Date(bookingData.endTime) < new Date() : false;
const isBookingInPast = bookingData
? new Date(bookingData.endTime) < new Date()
: false;
const changeMonth = (newMonth: number) => {
if (onMonthChange) {
onMonthChange(browsingDate.add(newMonth, "month"));
@@ -409,12 +457,24 @@ const DatePicker = ({
<div className="mb-1 flex items-center justify-between text-xl">
<span className="text-default w-1/2 text-base">
{browsingDate ? (
<time dateTime={browsingDate.format("YYYY-MM")} data-testid="selected-month-label">
<time
dateTime={browsingDate.format("YYYY-MM")}
data-testid="selected-month-label"
>
<strong
className={classNames(`text-emphasis font-semibold`, customClassNames?.datePickerTitle)}>
className={classNames(
`text-emphasis font-semibold`,
customClassNames?.datePickerTitle
)}
>
{month}
</strong>{" "}
<span className={classNames(`text-subtle font-medium`, customClassNames?.datePickerTitle)}>
<span
className={classNames(
`text-subtle font-medium`,
customClassNames?.datePickerTitle
)}
>
{browsingDate.format("YYYY")}
</span>
</time>
@@ -461,7 +521,8 @@ const DatePicker = ({
className={classNames(
`text-emphasis my-4 text-xs font-medium uppercase tracking-widest`,
customClassNames?.datePickerDays
)}>
)}
>
{weekDay}
</div>
))}
+1
View File
@@ -7,6 +7,7 @@ export type EmbedType = "inline" | "floating-popup" | "element-click" | "email"
type EmbedConfig = {
layout?: BookerLayouts;
theme?: Theme;
useSlotsViewOnSmallScreen?: "true" | "false";
};
export type EmbedState = {
+18
View File
@@ -6,6 +6,24 @@ import createFetchMock from "vitest-fetch-mock";
import type { CalendarService } from "@calcom/types/Calendar";
global.ResizeObserver = ResizeObserver;
if (typeof window !== "undefined") {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
const fetchMocker = createFetchMock(vi);
// sets globalThis.fetch and globalThis.fetchMock to our mocked version
+14
View File
@@ -104,6 +104,20 @@ global.ResizeObserver = vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
}));
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
expect.extend(matchers);
afterEach(() => {
+20
View File
@@ -0,0 +1,20 @@
import { vi } from "vitest";
// Mock window.matchMedia for jsdom environment
// This needs to be set up before any React components are rendered
const matchMediaMock = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: matchMediaMock,
});