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-21 15:44:33 -03:00
committed by GitHub
co-authored by rajiv@cal.com Devin AI Peer Richelsen Hariom Balhara
parent 6c620e37a6
commit 8c39210a12
29 changed files with 1420 additions and 260 deletions
@@ -1,7 +1,10 @@
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import dayjs from "@calcom/dayjs"; 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 { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
@@ -25,7 +28,10 @@ type AvailableTimeSlotsProps = {
seatsPerTimeSlot?: number | null; seatsPerTimeSlot?: number | null;
showAvailableSeatsCount?: boolean | null; showAvailableSeatsCount?: boolean | null;
event: { event: {
data?: Pick<BookerEvent, "length" | "bookingFields" | "price" | "currency" | "metadata"> | null; data?: Pick<
BookerEvent,
"length" | "bookingFields" | "price" | "currency" | "metadata"
> | null;
}; };
customClassNames?: { customClassNames?: {
availableTimeSlotsContainer?: string; availableTimeSlotsContainer?: string;
@@ -50,6 +56,7 @@ type AvailableTimeSlotsProps = {
unavailableTimeSlots: string[]; unavailableTimeSlots: string[];
confirmButtonDisabled?: boolean; confirmButtonDisabled?: boolean;
onAvailableTimeSlotSelect: (time: string) => void; onAvailableTimeSlotSelect: (time: string) => void;
hideAvailableTimesHeader?: boolean;
}; };
/** /**
@@ -74,16 +81,20 @@ export const AvailableTimeSlots = ({
confirmButtonDisabled, confirmButtonDisabled,
confirmStepClassNames, confirmStepClassNames,
onAvailableTimeSlotSelect, onAvailableTimeSlotSelect,
hideAvailableTimesHeader = false,
...props ...props
}: AvailableTimeSlotsProps) => { }: AvailableTimeSlotsProps) => {
const selectedDate = useBookerStoreContext((state) => state.selectedDate); 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 date = selectedDate || dayjs().format("YYYY-MM-DD");
const [layout] = useBookerStoreContext((state) => [state.layout]); const [layout] = useBookerStoreContext((state) => [state.layout]);
const isColumnView = layout === BookerLayouts.COLUMN_VIEW; const isColumnView = layout === BookerLayouts.COLUMN_VIEW;
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const { setTentativeSelectedTimeslots, tentativeSelectedTimeslots } = useBookerStoreContext((state) => ({ const { setTentativeSelectedTimeslots, tentativeSelectedTimeslots } =
useBookerStoreContext((state) => ({
setTentativeSelectedTimeslots: state.setTentativeSelectedTimeslots, setTentativeSelectedTimeslots: state.setTentativeSelectedTimeslots,
tentativeSelectedTimeslots: state.tentativeSelectedTimeslots, tentativeSelectedTimeslots: state.tentativeSelectedTimeslots,
})); }));
@@ -124,13 +135,22 @@ export const AvailableTimeSlots = ({
return []; return [];
}, [date, extraDays, nonEmptyScheduleDaysFromSelectedDate]); }, [date, extraDays, nonEmptyScheduleDaysFromSelectedDate]);
const { slotsPerDay, toggleConfirmButton } = useSlotsForAvailableDates(dates, scheduleData?.slots); const { slotsPerDay, toggleConfirmButton } = useSlotsForAvailableDates(
dates,
scheduleData?.slots
);
const overlayCalendarToggled = const overlayCalendarToggled =
getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault"); getQueryParam("overlayCalendar") === "true" ||
localStorage.getItem("overlayCalendarSwitchDefault");
const onTimeSelect = useCallback( 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 // 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) { 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 // 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 ( return (
<> <>
<div className={classNames(`flex`, `${customClassNames?.availableTimeSlotsContainer}`)}> <div
className={classNames(
`flex`,
hideAvailableTimesHeader && "hidden",
`${customClassNames?.availableTimeSlotsContainer}`
)}
>
{isLoading ? ( {isLoading ? (
<div className="mb-3 h-8" /> <div className="mb-3 h-8" />
) : ( ) : (
@@ -197,15 +229,19 @@ export const AvailableTimeSlots = ({
return ( return (
<AvailableTimesHeader <AvailableTimesHeader
customClassNames={{ customClassNames={{
availableTimeSlotsHeaderContainer: customClassNames?.availableTimeSlotsHeaderContainer, availableTimeSlotsHeaderContainer:
availableTimeSlotsTitle: customClassNames?.availableTimeSlotsTitle, customClassNames?.availableTimeSlotsHeaderContainer,
availableTimeSlotsTimeFormatToggle: customClassNames?.availableTimeSlotsTimeFormatToggle, availableTimeSlotsTitle:
customClassNames?.availableTimeSlotsTitle,
availableTimeSlotsTimeFormatToggle:
customClassNames?.availableTimeSlotsTimeFormatToggle,
}} }}
key={slots.date} key={slots.date}
date={dayjs(slots.date)} date={dayjs(slots.date)}
showTimeFormatToggle={!isColumnView && !isOOODay} showTimeFormatToggle={!isColumnView && !isOOODay}
availableMonth={ availableMonth={
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM") dayjs(selectedDate).format("MM") !==
dayjs(slots.date).format("MM")
? dayjs(slots.date).format("MMM") ? dayjs(slots.date).format("MMM")
: undefined : undefined
} }
@@ -221,13 +257,19 @@ export const AvailableTimeSlots = ({
limitHeight && "no-scrollbar grow overflow-auto md:h-[400px]", limitHeight && "no-scrollbar grow overflow-auto md:h-[400px]",
!limitHeight && "flex h-full w-full flex-row gap-4", !limitHeight && "flex h-full w-full flex-row gap-4",
`${customClassNames?.availableTimeSlotsContainer}` `${customClassNames?.availableTimeSlotsContainer}`
)}> )}
>
{isLoading && // Shows exact amount of days as skeleton. {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 && {!isLoading &&
slotsPerDay.length > 0 && slotsPerDay.length > 0 &&
slotsPerDay.map((slots) => ( 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 <AvailableTimes
className={customClassNames?.availableTimeSlotsContainer} className={customClassNames?.availableTimeSlotsContainer}
customClassNames={customClassNames?.availableTimes} customClassNames={customClassNames?.availableTimes}
@@ -13,12 +13,30 @@ import "@calcom/lib/__mocks__/logger";
import React from "react"; import React from "react";
import { vi } from "vitest"; 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/dayjs/__mocks__";
import "@calcom/features/auth/Turnstile"; import "@calcom/features/auth/Turnstile";
import { Booker } from "./Booker";
import { render, screen } from "@calcom/features/bookings/Booker/__tests__/test-utils"; import { render, screen } from "@calcom/features/bookings/Booker/__tests__/test-utils";
import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/Booker/types"; import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/Booker/types";
import { Booker } from "./Booker";
vi.mock("framer-motion", async (importOriginal) => { vi.mock("framer-motion", async (importOriginal) => {
const actual = (await importOriginal()) as any; const actual = (await importOriginal()) as any;
@@ -175,9 +193,12 @@ describe("Booker", () => {
}); });
it("should render null when in loading state", () => { it("should render null when in loading state", () => {
const { container } = render(<Booker {...defaultProps as unknown as BookerProps & WrappedBookerProps} />, { const { container } = render(
<Booker {...(defaultProps as unknown as BookerProps & WrappedBookerProps)} />,
{
mockStore: { state: "loading" }, mockStore: { state: "loading" },
}); }
);
expect(container).toBeEmptyDOMElement(); 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: { mockStore: {
state: "selecting_time", state: "selecting_time",
selectedDate: "2024-01-01", 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" }, mockStore: { state: "booking" },
}); });
screen.logTestingPlaygroundURL(); screen.logTestingPlaygroundURL();
@@ -240,7 +261,7 @@ describe("Booker", () => {
}, },
}; };
render(<Booker {...propsWithQuickChecks as unknown as BookerProps & WrappedBookerProps} />, { render(<Booker {...(propsWithQuickChecks as unknown as BookerProps & WrappedBookerProps)} />, {
mockStore: { state: "booking" }, mockStore: { state: "booking" },
}); });
const bookEventForm = screen.getByTestId("book-event-form"); const bookEventForm = screen.getByTestId("book-event-form");
@@ -18,6 +18,7 @@ import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun"; import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
import { isTimeSlotAvailable } from "@calcom/features/bookings/Booker/utils/isTimeslotAvailable"; import { isTimeSlotAvailable } from "@calcom/features/bookings/Booker/utils/isTimeslotAvailable";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; 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 { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils"; import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils";
import { import {
@@ -28,6 +29,7 @@ import {
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { BookerLayouts } from "@calcom/prisma/zod-utils";
import classNames from "@calcom/ui/classNames"; import classNames from "@calcom/ui/classNames";
import { DialogContent } from "@calcom/ui/components/dialog";
import { UnpublishedEntity } from "@calcom/ui/components/unpublished-entity"; import { UnpublishedEntity } from "@calcom/ui/components/unpublished-entity";
import PoweredBy from "@calcom/web/modules/ee/common/components/PoweredBy"; import PoweredBy from "@calcom/web/modules/ee/common/components/PoweredBy";
import { AnimatePresence, LazyMotion, m } from "framer-motion"; import { AnimatePresence, LazyMotion, m } from "framer-motion";
@@ -48,6 +50,7 @@ import { LargeCalendar } from "./LargeCalendar";
import { OverlayCalendar } from "./OverlayCalendar/OverlayCalendar"; import { OverlayCalendar } from "./OverlayCalendar/OverlayCalendar";
import { RedirectToInstantMeetingModal } from "./RedirectToInstantMeetingModal"; import { RedirectToInstantMeetingModal } from "./RedirectToInstantMeetingModal";
import { BookerSection } from "./Section"; import { BookerSection } from "./Section";
import { SlotSelectionModalHeader } from "./SlotSelectionModalHeader";
import { NotFound } from "./Unavailable"; import { NotFound } from "./Unavailable";
import { VerifyCodeDialog } from "./VerifyCodeDialog"; import { VerifyCodeDialog } from "./VerifyCodeDialog";
@@ -99,6 +102,11 @@ const BookerComponent = ({
const selectedDate = useBookerStoreContext((state) => state.selectedDate); const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const [isSlotSelectionModalVisible, setIsSlotSelectionModalVisible] = useBookerStoreContext(
(state) => [state.isSlotSelectionModalVisible, state.setIsSlotSelectionModalVisible],
shallow
);
const { const {
shouldShowFormInDialog, shouldShowFormInDialog,
hasDarkBackground, hasDarkBackground,
@@ -109,6 +117,7 @@ const BookerComponent = ({
hideEventTypeDetails, hideEventTypeDetails,
isEmbed, isEmbed,
bookerLayouts, bookerLayouts,
slotsViewOnSmallScreen,
} = bookerLayout; } = bookerLayout;
const [seatedEventData, setSeatedEventData] = useBookerStoreContext( const [seatedEventData, setSeatedEventData] = useBookerStoreContext(
@@ -181,6 +190,11 @@ const BookerComponent = ({
const scrolledToTimeslotsOnce = useRef(false); const scrolledToTimeslotsOnce = useRef(false);
const embedUiConfig = useEmbedUiConfig(); const embedUiConfig = useEmbedUiConfig();
const scrollToTimeSlots = () => { const scrollToTimeSlots = () => {
// Don't scroll if slots view on small screen is enabled
if (slotsViewOnSmallScreen) {
return;
}
if ( if (
isMobile && isMobile &&
!scrolledToTimeslotsOnce.current && !scrolledToTimeslotsOnce.current &&
@@ -213,6 +227,10 @@ const BookerComponent = ({
const onAvailableTimeSlotSelect = (time: string) => { const onAvailableTimeSlotSelect = (time: string) => {
setSelectedTimeslot(time); setSelectedTimeslot(time);
if (!skipConfirmStep) {
setIsSlotSelectionModalVisible(false);
}
}; };
updateEmbedBookerState({ bookerState, slotsQuery: schedule }); updateEmbedBookerState({ bookerState, slotsQuery: schedule });
@@ -268,7 +286,11 @@ const BookerComponent = ({
schedule?.invalidate(); schedule?.invalidate();
} }
if (seatedEventData.bookingUid) { if (seatedEventData.bookingUid) {
setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined }); setSeatedEventData({
...seatedEventData,
bookingUid: undefined,
attendees: undefined,
});
} }
}} }}
onSubmit={() => (renderConfirmNotVerifyEmailButtonCond ? handleBookEvent() : handleVerifyEmail())} onSubmit={() => (renderConfirmNotVerifyEmailButtonCond ? handleBookEvent() : handleVerifyEmail())}
@@ -460,6 +482,11 @@ const BookerComponent = ({
isLoading={schedule.isPending} isLoading={schedule.isPending}
scrollToTimeSlots={scrollToTimeSlots} scrollToTimeSlots={scrollToTimeSlots}
showNoAvailabilityDialog={showNoAvailabilityDialog} showNoAvailabilityDialog={showNoAvailabilityDialog}
onDateChange={() => {
if (slotsViewOnSmallScreen) {
setIsSlotSelectionModalVisible(true);
}
}}
/> />
</div> </div>
)} )}
@@ -493,7 +520,7 @@ const BookerComponent = ({
</BookerSection> </BookerSection>
<BookerSection <BookerSection
key="large-calendar" key="enable-calendar"
area="main" area="main"
visible={layout === BookerLayouts.WEEK_VIEW} visible={layout === BookerLayouts.WEEK_VIEW}
className="border-subtle sticky top-0 -ml-px h-full md:border-l" className="border-subtle sticky top-0 -ml-px h-full md:border-l"
@@ -505,12 +532,14 @@ const BookerComponent = ({
event={event} event={event}
/> />
</BookerSection> </BookerSection>
<BookerSection <BookerSection
key="timeslots" key="timeslots"
area={{ default: "main", month_view: "timeslots" }} area={{ default: "main", month_view: "timeslots" }}
visible={ visible={
(layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") || !(slotsViewOnSmallScreen && isMobile) &&
layout === BookerLayouts.COLUMN_VIEW ((layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
layout === BookerLayouts.COLUMN_VIEW)
} }
className={classNames( 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", "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}> visible={bookerState === "booking" && shouldShowFormInDialog}>
{EventBooker} {EventBooker}
</BookFormAsModal> </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" /> <Toaster position="bottom-right" />
</> </>
); );
@@ -6,10 +6,7 @@ import { useMemo, useCallback, useEffect } from "react";
import React from "react"; import React from "react";
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import { import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
sdkActionManager,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { useBookerEmbedEvents } from "@calcom/embed-core/src/embed-iframe/react-hooks"; import { useBookerEmbedEvents } from "@calcom/embed-core/src/embed-iframe/react-hooks";
import type { BookerProps } from "@calcom/features/bookings/Booker"; import type { BookerProps } from "@calcom/features/bookings/Booker";
import { 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 { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail"; import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; 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 { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; 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 { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { localStorage } from "@calcom/lib/webstorage"; import { localStorage } from "@calcom/lib/webstorage";
@@ -60,11 +64,17 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const isRedirect = searchParams?.get("redirected") === "true" || false; const isRedirect = searchParams?.get("redirected") === "true" || false;
const fromUserNameRedirected = searchParams?.get("username") || ""; const fromUserNameRedirected = searchParams?.get("username") || "";
const rescheduleUid = 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 = 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 = 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; const timezone = searchParams?.get("cal.tz") || null;
useEffect(() => { useEffect(() => {
@@ -92,7 +102,10 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
timezone, timezone,
}); });
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow); const [dayCount] = useBookerStoreContext(
(state) => [state.dayCount, state.setDayCount],
shallow
);
const { data: session } = useSession(); const { data: session } = useSession();
const routerQuery = useRouterQuery(); const routerQuery = useRouterQuery();
@@ -104,7 +117,8 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
.reduce( .reduce(
(metadata, key) => ({ (metadata, key) => ({
...metadata, ...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 { return {
name: name:
searchParams?.get("name") || searchParams?.get("name") ||
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null), (firstNameQueryParam
guests: (searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [], ? `${firstNameQueryParam} ${lastNameQueryParam}`
: null),
guests:
(searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
}; };
}, [searchParams, firstNameQueryParam, lastNameQueryParam]); }, [searchParams, firstNameQueryParam, lastNameQueryParam]);
@@ -130,10 +147,13 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const verifyEmail = useVerifyEmail({ const verifyEmail = useVerifyEmail({
email: bookerForm.formEmail, email: bookerForm.formEmail,
name: bookerForm.formName, name: bookerForm.formName,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification, requiresBookerEmailVerification:
event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail, 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(); const isEmbed = useIsEmbed();
@@ -197,7 +217,8 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
); );
useBrandColors({ useBrandColors({
brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR, 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, theme: event.data?.profile.theme,
}); });
@@ -10,6 +10,7 @@ import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { User } from "@calcom/prisma/client"; import type { User } from "@calcom/prisma/client";
import type { PeriodData } from "@calcom/types/Event"; 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"; import type { Slots } from "@calcom/features/bookings/types";
@@ -32,8 +33,8 @@ const useMoveToNextMonthOnNoAvailability = ({
}; };
} }
const nonEmptyScheduleDaysInBrowsingMonth = nonEmptyScheduleDays.filter((date) => const nonEmptyScheduleDaysInBrowsingMonth = nonEmptyScheduleDays.filter(
dayjs(date).isSame(browsingDate, "month") (date) => dayjs(date).isSame(browsingDate, "month")
); );
const moveToNextMonthOnNoAvailability = () => { const moveToNextMonthOnNoAvailability = () => {
@@ -41,7 +42,10 @@ const useMoveToNextMonthOnNoAvailability = ({
const browsingMonth = browsingDate.format("YYYY-MM"); const browsingMonth = browsingDate.format("YYYY-MM");
// Not meeting the criteria to move to next month // Not meeting the criteria to move to next month
// Has to be currentMonth and it must have all days unbookable // Has to be currentMonth and it must have all days unbookable
if (currentMonth != browsingMonth || nonEmptyScheduleDaysInBrowsingMonth.length) { if (
currentMonth != browsingMonth ||
nonEmptyScheduleDaysInBrowsingMonth.length
) {
return; return;
} }
onMonthChange(browsingDate.add(1, "month")); onMonthChange(browsingDate.add(1, "month"));
@@ -58,6 +62,7 @@ export const DatePicker = ({
classNames, classNames,
scrollToTimeSlots, scrollToTimeSlots,
showNoAvailabilityDialog, showNoAvailabilityDialog,
onDateChange,
}: { }: {
event: { event: {
data?: { data?: {
@@ -74,6 +79,7 @@ export const DatePicker = ({
classNames?: DatePickerClassNames; classNames?: DatePickerClassNames;
scrollToTimeSlots?: () => void; scrollToTimeSlots?: () => void;
showNoAvailabilityDialog?: boolean; showNoAvailabilityDialog?: boolean;
onDateChange?: () => void;
}) => { }) => {
const { i18n } = useLocale(); const { i18n } = useLocale();
const [month, selectedDate, layout] = useBookerStoreContext( const [month, selectedDate, layout] = useBookerStoreContext(
@@ -86,16 +92,21 @@ export const DatePicker = ({
shallow shallow
); );
const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen();
const onMonthChange = (date: Dayjs) => { const onMonthChange = (date: Dayjs) => {
setMonth(date.format("YYYY-MM")); setMonth(date.format("YYYY-MM"));
if (!slotsViewOnSmallScreen) {
setSelectedDate({ date: date.format("YYYY-MM-DD") }); setSelectedDate({ date: date.format("YYYY-MM-DD") });
}
setDayCount(null); // Whenever the month is changed, we nullify getting X days setDayCount(null); // Whenever the month is changed, we nullify getting X days
}; };
const nonEmptyScheduleDays = useNonEmptyScheduleDays(slots); const nonEmptyScheduleDays = useNonEmptyScheduleDays(slots);
const browsingDate = month ? dayjs(month) : dayjs().startOf("month"); const browsingDate = month ? dayjs(month) : dayjs().startOf("month");
const { moveToNextMonthOnNoAvailability } = useMoveToNextMonthOnNoAvailability({ const { moveToNextMonthOnNoAvailability } =
useMoveToNextMonthOnNoAvailability({
browsingDate, browsingDate,
nonEmptyScheduleDays, nonEmptyScheduleDays,
onMonthChange, onMonthChange,
@@ -134,11 +145,19 @@ export const DatePicker = ({
className={classNames?.datePickerContainer} className={classNames?.datePickerContainer}
isLoading={isLoading} isLoading={isLoading}
onChange={(date: Dayjs | null, omitUpdatingParams?: boolean) => { onChange={(date: Dayjs | null, omitUpdatingParams?: boolean) => {
const newDate = date === null ? null : date.format("YYYY-MM-DD");
const previousDate = selectedDate;
const dateChanged = newDate !== previousDate;
setSelectedDate({ setSelectedDate({
date: date === null ? date : date.format("YYYY-MM-DD"), date: date === null ? null : date.format("YYYY-MM-DD"),
omitUpdatingParams, omitUpdatingParams,
preventMonthSwitching: !isCompact, // Prevent month switching when in monthly view preventMonthSwitching: !isCompact, // Prevent month switching when in monthly view
}); });
if (dateChanged) {
onDateChange?.();
}
}} }}
onMonthChange={onMonthChange} onMonthChange={onMonthChange}
includedDates={nonEmptyScheduleDays} 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 ( return (
<DialogContent className="rounded-lg p-10" type="creation" size="lg"> <DialogContent className="rounded-lg p-10" type="creation" size="lg">
<div className="mb-2"> <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 })} {t("how_you_want_add_cal_site", { appName: APP_NAME })}
</h3> </h3>
<div> <div>
@@ -864,6 +866,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
useState(true); useState(true);
const defaultConfig = { const defaultConfig = {
layout: BookerLayouts.MONTH_VIEW, layout: BookerLayouts.MONTH_VIEW,
useSlotsViewOnSmallScreen: "true" as const,
}; };
const paletteDefaultValue = (paletteName: string) => { const paletteDefaultValue = (paletteName: string) => {
+14
View File
@@ -7,6 +7,20 @@ global.React = React;
global.ResizeObserver = ResizeObserver; global.ResizeObserver = ResizeObserver;
expect.extend(matchers); 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(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
+3 -1
View File
@@ -120,6 +120,7 @@ Cal.modal({
calLink: "organization/event-type", calLink: "organization/event-type",
config: { config: {
// Optional configuration // Optional configuration
useSlotsViewOnSmallScreen: "true"
} }
}); });
``` ```
@@ -144,7 +145,8 @@ Cal.inline({
config: { config: {
name: "John Doe", name: "John Doe",
email: "john@example.com", email: "john@example.com",
notes: "Initial discussion" notes: "Initial discussion",
useSlotsViewOnSmallScreen: "true"
} }
}); });
``` ```
+37 -6
View File
@@ -49,16 +49,16 @@
// Put the snippet in a function so that it can be re-executed for scenario testing // Put the snippet in a function so that it can be re-executed for scenario testing
// TODO: How to reuse embed-snippet export here? // TODO: How to reuse embed-snippet export here?
function embedSnippet() { function embedSnippet() {
(function (C, A, L) { ((C, A, L) => {
let p = function (a, ar) { const p = (a, ar) => {
a.q.push(ar); a.q.push(ar);
}; };
let d = C.document; const d = C.document;
C.Cal = C.Cal =
C.Cal || C.Cal ||
function () { function () {
let cal = C.Cal; const cal = C.Cal;
let ar = arguments; const ar = arguments;
if (!cal.loaded) { if (!cal.loaded) {
cal.ns = {}; cal.ns = {};
cal.q = cal.q || []; cal.q = cal.q || [];
@@ -484,6 +484,14 @@
</div> </div>
</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"> <div id="cal-booking-place-skeletonDemo">
<h3><a href="?only=ns:skeletonDemo">Skeleton Loader INLINE Demo</a></h3> <h3><a href="?only=ns:skeletonDemo">Skeleton Loader INLINE Demo</a></h3>
<div class="inline-embed-container"> <div class="inline-embed-container">
@@ -505,9 +513,32 @@
</div> </div>
<script type="module" src="./playground/lib/playground.ts"></script> <script type="module" src="./playground/lib/playground.ts"></script>
<script> <script>
if (initialNamespace.startsWith("skeletonDemo")) { if (initialNamespace.startsWith("skeletonDemo") || initialNamespace === "twoStepSlotSelection") {
document.getElementById("misc-embeds").style.display = "none"; 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> </script>
</body> </body>
@@ -1,11 +1,11 @@
import type { GlobalCal, EmbedEvent } from "../../src/embed"; import type { EmbedEvent, GlobalCal } from "../../src/embed";
const Cal = window.Cal as GlobalCal; const Cal = window.Cal as GlobalCal;
Cal.config = Cal.config || {}; Cal.config = Cal.config || {};
Cal.config.forwardQueryParams = true; Cal.config.forwardQueryParams = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const callback = function (e: any) { const callback = (e: any) => {
const detail = e.detail; const detail = e.detail;
}; };
@@ -45,21 +45,17 @@ if (themeInParam && !theme) {
const calLink = searchParams.get("cal-link"); const calLink = searchParams.get("cal-link");
function fakeEvent({ function fakeEvent({ namespace, eventType, data }) {
namespace,
eventType,
data
}) {
window.postMessage({ window.postMessage({
fullType: `CAL:${namespace}:${eventType}`, fullType: `CAL:${namespace}:${eventType}`,
namespace, namespace,
originator: "CAL", originator: "CAL",
type: eventType, type: eventType,
data, data,
}) });
} }
window.heavilyCustomizeUi = function ({ namespace }) { window.heavilyCustomizeUi = ({ namespace }) => {
Cal.ns[namespace]("ui", { Cal.ns[namespace]("ui", {
theme: "light", theme: "light",
cssVarsPerTheme: { cssVarsPerTheme: {
@@ -94,11 +90,11 @@ window.heavilyCustomizeUi = function ({ namespace }) {
}); });
}; };
window.fakeErrorScenario = function ({ namespace }) { window.fakeErrorScenario = ({ namespace }) => {
fakeEvent({ fakeEvent({
namespace, namespace,
eventType: "linkFailed", eventType: "linkFailed",
data: { code: 500 } data: { code: 500 },
}); });
}; };
@@ -137,7 +133,7 @@ if (only === "all" || only === "ns:second") {
Cal.ns.second( Cal.ns.second(
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-second .place", elementOrSelector: "#cal-booking-place-second .place",
calLink: "pro?case=2", calLink: "pro?case=2",
@@ -166,7 +162,7 @@ if (only === "all" || only === "ns:third") {
[ [
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-third .place", elementOrSelector: "#cal-booking-place-third .place",
calLink: "pro/30min", calLink: "pro/30min",
@@ -217,7 +213,7 @@ if (only === "all" || only === "ns:fourth") {
[ [
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-fourth .place", elementOrSelector: "#cal-booking-place-fourth .place",
calLink: "team/seeded-team", calLink: "team/seeded-team",
@@ -262,7 +258,7 @@ if (only === "all" || only === "ns:corpTest") {
Cal.ns.corpTest([ Cal.ns.corpTest([
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-corpTest .place", elementOrSelector: "#cal-booking-place-corpTest .place",
calLink: "pro", calLink: "pro",
@@ -283,7 +279,7 @@ if (only === "all" || only === "ns:fifth") {
Cal.ns.fifth([ Cal.ns.fifth([
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-fifth .place", elementOrSelector: "#cal-booking-place-fifth .place",
calLink: "team/seeded-team/collective-seeded-team-event", calLink: "team/seeded-team/collective-seeded-team-event",
@@ -330,7 +326,7 @@ if (only === "all" || only === "inline-routing-form") {
Cal.ns["inline-routing-form"]([ Cal.ns["inline-routing-form"]([
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-inline-routing-form .place", elementOrSelector: "#cal-booking-place-inline-routing-form .place",
calLink: "forms/948ae412-d995-4865-875a-48302588de03", calLink: "forms/948ae412-d995-4865-875a-48302588de03",
@@ -355,7 +351,7 @@ if (only === "all" || only === "ns:hideEventTypeDetails") {
[ [
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: `#cal-booking-place-${identifier} .place`, elementOrSelector: `#cal-booking-place-${identifier} .place`,
calLink: "free/30min", calLink: "free/30min",
@@ -528,7 +524,7 @@ if (only === "all" || only == "ns:monthView") {
Cal.ns.monthView( Cal.ns.monthView(
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-monthView .place", elementOrSelector: "#cal-booking-place-monthView .place",
calLink: "free/30min", calLink: "free/30min",
@@ -553,7 +549,7 @@ if (only === "all" || only == "ns:weekView") {
Cal.ns.weekView( Cal.ns.weekView(
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-weekView .place", elementOrSelector: "#cal-booking-place-weekView .place",
calLink: "free/30min", calLink: "free/30min",
@@ -582,7 +578,7 @@ if (only === "all" || only == "ns:columnView") {
Cal.ns.columnView( Cal.ns.columnView(
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-columnView .place", elementOrSelector: "#cal-booking-place-columnView .place",
calLink: "free/30min", 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") { 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 // Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "columnViewHideEventTypeDetails", { Cal("init", "columnViewHideEventTypeDetails", {
@@ -612,7 +641,7 @@ if (only === "all" || only == "ns:columnViewHideEventTypeDetails") {
Cal.ns.columnViewHideEventTypeDetails( Cal.ns.columnViewHideEventTypeDetails(
"inline", "inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-expect-error
{ {
elementOrSelector: "#cal-booking-place-columnViewHideEventTypeDetails .place", elementOrSelector: "#cal-booking-place-columnViewHideEventTypeDetails .place",
calLink: "free/30min", calLink: "free/30min",
@@ -127,4 +127,49 @@ test.describe("Embed Pages", () => {
expect(embedTheme).toBe(theme); 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); 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);
});
});
}); });
+76 -23
View File
@@ -3,7 +3,13 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { mapOldToNewCssVars } from "./ui/cssVarsMap"; import { mapOldToNewCssVars } from "./ui/cssVarsMap";
import type { Message } from "./embed"; 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 { import {
runAsap, runAsap,
isBookerReady, isBookerReady,
@@ -72,7 +78,9 @@ if (isBrowser) {
const setEmbedStyles = (stylesConfig: EmbedStyles) => { const setEmbedStyles = (stylesConfig: EmbedStyles) => {
embedStore.styles = stylesConfig; embedStore.styles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { for (const [, setEmbedStyle] of Object.entries(
embedStore.reactStylesStateSetters
)) {
setEmbedStyle((styles) => { setEmbedStyle((styles) => {
return { return {
...styles, ...styles,
@@ -84,7 +92,9 @@ const setEmbedStyles = (stylesConfig: EmbedStyles) => {
const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => { const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
embedStore.nonStyles = stylesConfig; embedStore.nonStyles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { for (const [, setEmbedStyle] of Object.entries(
embedStore.reactStylesStateSetters
)) {
setEmbedStyle((styles) => { setEmbedStyle((styles) => {
return { return {
...styles, ...styles,
@@ -110,14 +120,17 @@ const registerNewSetter = (
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe. // 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 // So, we should call the setter immediately with available embedStyles
if (registration.styles) { 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 || {}); registration.setState(embedStore.styles || {});
return () => { return () => {
delete embedStore.reactStylesStateSetters[registration.elementName]; delete embedStore.reactStylesStateSetters[registration.elementName];
}; };
} else { } else {
embedStore.reactNonStylesStateSetters[registration.elementName as keyof EmbedNonStylesConfig] = embedStore.reactNonStylesStateSetters[
registration.setState; registration.elementName as keyof EmbedNonStylesConfig
] = registration.setState;
registration.setState(embedStore.nonStyles || {}); registration.setState(embedStore.nonStyles || {});
return () => { return () => {
@@ -173,7 +186,9 @@ export const useEmbedUiConfig = () => {
embedStore.setUiConfig.push(setUiConfig); embedStore.setUiConfig.push(setUiConfig);
useEffect(() => { useEffect(() => {
return () => { return () => {
const foundAtIndex = embedStore.setUiConfig.findIndex((item) => item === setUiConfig); const foundAtIndex = embedStore.setUiConfig.findIndex(
(item) => item === setUiConfig
);
// Keep removing the setters that are stale // Keep removing the setters that are stale
embedStore.setUiConfig.splice(foundAtIndex, 1); embedStore.setUiConfig.splice(foundAtIndex, 1);
}; };
@@ -186,18 +201,28 @@ export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
const [, setStyles] = useState<EmbedStyles>({}); const [, setStyles] = useState<EmbedStyles>({});
useEffect(() => { useEffect(() => {
return registerNewSetter({ elementName, setState: setStyles, styles: true }); return registerNewSetter({
elementName,
setState: setStyles,
styles: true,
});
}, []); }, []);
const styles = embedStore.styles || {}; const styles = embedStore.styles || {};
// Always read the data from global embedStore so that even across components, the same data is used. // Always read the data from global embedStore so that even across components, the same data is used.
return styles[elementName] || {}; return styles[elementName] || {};
}; };
export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => { export const useEmbedNonStylesConfig = (
elementName: keyof EmbedNonStylesConfig
) => {
const [, setNonStyles] = useState({} as EmbedNonStylesConfig); const [, setNonStyles] = useState({} as EmbedNonStylesConfig);
useEffect(() => { 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. // 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 = () => { export const useBrandColors = () => {
// TODO: Branding shouldn't be part of ui.styles. It should exist as ui.branding. // 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 || {}; return brandingColors || {};
}; };
@@ -240,7 +267,8 @@ function getEmbedType() {
} }
if (isBrowser) { if (isBrowser) {
const url = new URL(document.URL); const url = new URL(document.URL);
const embedType = (embedStore.embedType = url.searchParams.get("embedType")); const embedType = (embedStore.embedType =
url.searchParams.get("embedType"));
return embedType; return embedType;
} }
} }
@@ -314,7 +342,8 @@ async function ensureRoutingFormResponseIdInUrl({
// Update routingFormResponseId in url only after connect is completed, to keep things simple // 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. // 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({ const { stopEnsuringQueryParamsInUrl } =
embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: { toBeThereParams: {
...toBeThereParams, ...toBeThereParams,
"cal.routingFormResponseId": newlyRecordedResponseId.toString(), "cal.routingFormResponseId": newlyRecordedResponseId.toString(),
@@ -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"}) // It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
export const methods = { export const methods = {
ui: function style(uiConfig: UiConfig) { ui: function style(uiConfig: UiConfig) {
@@ -371,7 +399,10 @@ export const methods = {
let mergedCssVarsPerTheme: UiConfig["cssVarsPerTheme"] | undefined; let mergedCssVarsPerTheme: UiConfig["cssVarsPerTheme"] | undefined;
if (oldCssVarsPerTheme || newCssVarsPerTheme) { if (oldCssVarsPerTheme || newCssVarsPerTheme) {
mergedCssVarsPerTheme = {} as Record<"light" | "dark", Record<string, string>>; mergedCssVarsPerTheme = {} as Record<
"light" | "dark",
Record<string, string>
>;
const themeKeys = [ const themeKeys = [
...(oldCssVarsPerTheme ? Object.keys(oldCssVarsPerTheme) : []), ...(oldCssVarsPerTheme ? Object.keys(oldCssVarsPerTheme) : []),
...(newCssVarsPerTheme ? Object.keys(newCssVarsPerTheme) : []), ...(newCssVarsPerTheme ? Object.keys(newCssVarsPerTheme) : []),
@@ -389,11 +420,15 @@ export const methods = {
uiConfig = { uiConfig = {
...embedStore.uiConfig, ...embedStore.uiConfig,
...uiConfig, ...uiConfig,
...(mergedCssVarsPerTheme ? { cssVarsPerTheme: mergedCssVarsPerTheme } : {}), ...(mergedCssVarsPerTheme
? { cssVarsPerTheme: mergedCssVarsPerTheme }
: {}),
}; };
if (uiConfig.cssVarsPerTheme) { if (uiConfig.cssVarsPerTheme) {
const mappedCssVarsPerTheme = mapOldToNewCssVars(uiConfig.cssVarsPerTheme); const mappedCssVarsPerTheme = mapOldToNewCssVars(
uiConfig.cssVarsPerTheme
);
window.CalEmbed.applyCssVars(mappedCssVarsPerTheme); window.CalEmbed.applyCssVars(mappedCssVarsPerTheme);
} }
@@ -458,7 +493,9 @@ export const methods = {
embedStore.providedCorrectHeightToParent = false; embedStore.providedCorrectHeightToParent = false;
if (noSlotsFetchOnConnect !== "true") { 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 // Incrementing the version forces the slots call to be made again
embedStore.connectVersion = embedStore.connectVersion + 1; embedStore.connectVersion = embedStore.connectVersion + 1;
} }
@@ -503,7 +540,9 @@ export const methods = {
}; };
export type InterfaceWithParent = { 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; export const interfaceWithParent: InterfaceWithParent = methods;
@@ -528,11 +567,18 @@ function main() {
const autoScrollFromParam = url.searchParams.get("ui.autoscroll"); const autoScrollFromParam = url.searchParams.get("ui.autoscroll");
const shouldDisableAutoScroll = autoScrollFromParam === "false"; const shouldDisableAutoScroll = autoScrollFromParam === "false";
const useSlotsViewOnSmallScreenParam = url.searchParams.get(
"useSlotsViewOnSmallScreen"
);
embedStore.uiConfig = { embedStore.uiConfig = {
// TODO: Add theme as well here // TODO: Add theme as well here
colorScheme: url.searchParams.get("ui.color-scheme"), colorScheme: url.searchParams.get("ui.color-scheme"),
layout: url.searchParams.get("layout") as BookerLayouts, layout: url.searchParams.get("layout") as BookerLayouts,
disableAutoScroll: shouldDisableAutoScroll, disableAutoScroll: shouldDisableAutoScroll,
// by default useSlotsViewOnSmallScreen should be false
useSlotsViewOnSmallScreen:
(useSlotsViewOnSmallScreenParam ?? "false") === "true",
}; };
actOnColorScheme(embedStore.uiConfig.colorScheme); actOnColorScheme(embedStore.uiConfig.colorScheme);
@@ -544,7 +590,8 @@ function main() {
return; return;
} }
const willSlotsBeFetched = url.searchParams.get("cal.skipSlotsFetch") !== "true"; const willSlotsBeFetched =
url.searchParams.get("cal.skipSlotsFetch") !== "true";
log(`Slots will ${willSlotsBeFetched ? "" : "NOT "}be fetched`); log(`Slots will ${willSlotsBeFetched ? "" : "NOT "}be fetched`);
window.addEventListener("message", (e) => { window.addEventListener("message", (e) => {
@@ -582,7 +629,10 @@ function main() {
}); });
sdkActionManager?.on("*", (e) => { sdkActionManager?.on("*", (e) => {
if (isPrerendering() && !eventsAllowedInPrerendering.includes(e.detail.type)) { if (
isPrerendering() &&
!eventsAllowedInPrerendering.includes(e.detail.type)
) {
return; return;
} }
const detail = e.detail; const detail = e.detail;
@@ -593,7 +643,9 @@ function main() {
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) { if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
initializeAndSetupEmbed(); initializeAndSetupEmbed();
} else { } 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,7 +722,8 @@ async function connectPreloadedEmbed({
toBeThereParams: Record<string, string | string[]>; toBeThereParams: Record<string, string | string[]>;
toRemoveParams: string[]; toRemoveParams: string[];
}) { }) {
const { hasChanged, stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ const { hasChanged, stopEnsuringQueryParamsInUrl } =
embedStore.router.ensureQueryParamsInUrl({
toBeThereParams, toBeThereParams,
toRemoveParams, toRemoveParams,
}); });
+3
View File
@@ -33,6 +33,7 @@ export type UiConfig = {
layout?: BookerLayouts; layout?: BookerLayouts;
colorScheme?: string | null; colorScheme?: string | null;
disableAutoScroll?: boolean; disableAutoScroll?: boolean;
useSlotsViewOnSmallScreen?: boolean;
}; };
declare global { declare global {
@@ -71,6 +72,8 @@ export type KnownConfig = {
"cal.embed.pageType"?: EmbedPageType; "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. // 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"; "cal.embed.noSlotsFetchOnConnect"?: "true" | "false";
// If true, enables slots view on small screen in the booker
useSlotsViewOnSmallScreen?: "true" | "false";
}; };
export type EmbedBookerState = export type EmbedBookerState =
@@ -15,14 +15,18 @@ function App() {
embedJsUrl: "http://localhost:3000/embed/embed.js", embedJsUrl: "http://localhost:3000/embed/embed.js",
namespace: calNamespace, namespace: calNamespace,
}); });
cal("ui", { styles: { branding: { brandColor: "#000000" } }, hideEventTypeDetails: false }); cal("ui", {
styles: { branding: { brandColor: "#000000" } },
hideEventTypeDetails: false,
});
})(); })();
}, []); }, []);
return ( return (
<button <button
data-cal-namespace={calNamespace} data-cal-namespace={calNamespace}
data-cal-link="pro" data-cal-link="pro"
data-cal-config='{"layout":"month_view", "theme":"dark"}'> data-cal-config='{"layout":"month_view", "theme":"dark", "useSlotsViewOnSmallScreen":"true"}'
>
Click me Click me
</button> </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 // 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; const data = e.detail.data;
console.log("bookingSuccessfulV2", { console.log("bookingSuccessfulV2", {
endTime: data.endTime, endTime: data.endTime,
@@ -109,6 +111,7 @@ function App() {
guests: ["janedoe@gmail.com"], guests: ["janedoe@gmail.com"],
theme: "dark", theme: "dark",
"cal.embed.pageType": "user.event.booking.slots", "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 dayjs from "@calcom/dayjs";
import { BookerLayouts } from "@calcom/prisma/zod-utils"; 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 { BookerStoreContext } from "../BookerStoreProvider";
import type { BookerStore } from "../store"; import type { BookerStore } from "../store";
@@ -72,6 +70,8 @@ const createMockStore = (initialState?: Partial<BookerStore>): StoreApi<BookerSt
allowUpdatingUrlParams: true, allowUpdatingUrlParams: true,
verificationCode: null, verificationCode: null,
setVerificationCode: vi.fn(), setVerificationCode: vi.fn(),
isSlotSelectionModalVisible: false,
setIsSlotSelectionModalVisible: vi.fn(),
...initialState, ...initialState,
}; };
@@ -100,7 +100,7 @@ export const renderWithBookerStore = (
): ReturnType<typeof render> => { ): ReturnType<typeof render> => {
const mockStore = createMockStore(options?.mockStore); 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> <BookerStoreContext.Provider value={mockStore}>{children}</BookerStoreContext.Provider>
); );
@@ -1,12 +1,17 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { shallow } from "zustand/shallow"; 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 { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import type { BookerLayouts } from "@calcom/prisma/zod-utils"; import type { BookerLayouts } from "@calcom/prisma/zod-utils";
import { defaultBookerLayoutSettings } 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 { extraDaysConfig } from "../../config";
import type { BookerLayout } from "../../types"; import type { BookerLayout } from "../../types";
@@ -16,17 +21,29 @@ import { getQueryParam } from "../../utils/query-param";
export type UseBookerLayoutType = ReturnType<typeof useBookerLayout>; export type UseBookerLayoutType = ReturnType<typeof useBookerLayout>;
export const 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 isEmbed = useIsEmbed();
const isMobile = useMediaQuery("(max-width: 768px)"); const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)"); const isTablet = useMediaQuery("(max-width: 1024px)");
const embedUiConfig = useEmbedUiConfig(); 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 // 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' // 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 layout = isEmbed
const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop; ? isMobile
? "mobile"
: validateLayout(embedUiConfig.layout) || _layout
: _layout;
const extraDays = isTablet
? extraDaysConfig[layout].tablet
: extraDaysConfig[layout].desktop;
const embedType = useEmbedType(); const embedType = useEmbedType();
// Floating Button and Element Click both are modal and thus have dark background // Floating Button and Element Click both are modal and thus have dark background
const hasDarkBackground = isEmbed && embedType !== "inline"; const hasDarkBackground = isEmbed && embedType !== "inline";
@@ -55,7 +72,9 @@ export const useBookerLayout = (
bookerLayouts?.enabledLayouts?.length && bookerLayouts?.enabledLayouts?.length &&
layout !== _layout layout !== _layout
) { ) {
const validLayout = bookerLayouts.enabledLayouts.find((userLayout) => userLayout === layout); const validLayout = bookerLayouts.enabledLayouts.find(
(userLayout) => userLayout === layout
);
if (validLayout) setLayout(validLayout); if (validLayout) setLayout(validLayout);
} }
}, [bookerLayouts, setLayout, _layout, isEmbed, isMobile]); }, [bookerLayouts, setLayout, _layout, isEmbed, isMobile]);
@@ -83,6 +102,8 @@ export const useBookerLayout = (
? hideEventTypeDetailsParam === "true" ? hideEventTypeDetailsParam === "true"
: false; : false;
const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen();
return { return {
shouldShowFormInDialog, shouldShowFormInDialog,
hasDarkBackground, hasDarkBackground,
@@ -94,6 +115,7 @@ export const useBookerLayout = (
layout, layout,
defaultLayout, defaultLayout,
hideEventTypeDetails, hideEventTypeDetails,
slotsViewOnSmallScreen,
bookerLayouts, bookerLayouts,
}; };
}; };
@@ -2,13 +2,24 @@ import { useEffect } from "react";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; 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 today = dayjs();
const weekStart = today.startOf("week").format("YYYY-MM-DD"); const weekStart = today.startOf("week").format("YYYY-MM-DD");
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate); const setSelectedDate = useBookerStoreContext(
(state) => state.setSelectedDate
);
useEffect(() => { 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) { if (isPlatform && isCalendarView) {
setSelectedDate({ date: weekStart, omitUpdatingParams: true }); 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 type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { getBookingResponsesSchemaWithOptionalChecks } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; import { getBookingResponsesSchemaWithOptionalChecks } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { useEffect, useState } from "react";
import type { BookerEvent } from "../../../types"; import type { BookerEvent } from "../../../types";
import type { BookerState } 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 { useEffect } from "react";
import { createWithEqualityFn } from "zustand/traditional"; import { createWithEqualityFn } from "zustand/traditional";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { BOOKER_NUMBER_OF_DAYS_TO_LOAD } from "@calcom/lib/constants"; import { BOOKER_NUMBER_OF_DAYS_TO_LOAD } from "@calcom/lib/constants";
import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { BookerLayouts } from "@calcom/prisma/zod-utils";
import type { GetBookingType } from "../lib/get-booking"; import type { GetBookingType } from "../lib/get-booking";
import type { BookerState, BookerLayout } from "./types"; 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 = [ const _iso_3166_1_alpha_2_codes = [
"ad", "ae", "af", "ag", "ai", "al", "am", "ao", "aq", "ar", "as", "at", "au", "aw", "ax", "az", "ad",
"ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bl", "bm", "bn", "bo", "bq", "br", "bs", "bt", "bv", "bw", "by", "bz", "ae",
"ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "cr", "cu", "cv", "cw", "cx", "cy", "cz", "af",
"de", "dj", "dk", "dm", "do", "dz", "ag",
"ec", "ee", "eg", "eh", "er", "es", "et", "ai",
"fi", "fj", "fk", "fm", "fo", "fr", "al",
"ga", "gb", "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy", "am",
"hk", "hm", "hn", "hr", "ht", "hu", "ao",
"id", "ie", "il", "im", "in", "io", "iq", "ir", "is", "it", "aq",
"je", "jm", "jo", "jp", "ar",
"ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", "kz", "as",
"la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly", "at",
"ma", "mc", "md", "me", "mf", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz", "au",
"na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz", "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", "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", "qa",
"re", "ro", "rs", "ru", "rw", "re",
"sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sr", "ss", "st", "sv", "sx", "sy", "ro",
"tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tr", "tt", "tv", "tw", "tz", "rs",
"ua", "ug", "um", "us", "uy", "uz", "ru",
"va", "vc", "ve", "vg", "vi", "vn", "vu", "rw",
"wf", "ws", "sa",
"ye", "yt", "sb",
"za", "zm", "zw" "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; ] 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 * Arguments passed into store initializer, containing
@@ -135,7 +357,9 @@ export type BookerStore = {
* Multiple Selected Dates and Times * Multiple Selected Dates and Times
*/ */
selectedDatesAndTimes: { [key: string]: { [key: string]: string[] } } | null; 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 * Multiple duration configuration
*/ */
@@ -215,6 +439,11 @@ export type BookerStore = {
isPlatform?: boolean; isPlatform?: boolean;
allowUpdatingUrlParams?: boolean; allowUpdatingUrlParams?: boolean;
defaultPhoneCountry?: CountryCode | null; 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) => { setLayout: (layout: BookerLayout) => {
// If we switch to a large layout and don't have a date selected yet, // 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. // 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") }); set({ selectedDate: dayjs().format("YYYY-MM-DD") });
} }
if (!get().isPlatform || get().allowUpdatingUrlParams) { if (!get().isPlatform || get().allowUpdatingUrlParams) {
@@ -237,7 +469,11 @@ export const createBookerStore = () =>
return set({ layout }); return set({ layout });
}, },
selectedDate: getQueryParam("date") || null, selectedDate: getQueryParam("date") || null,
setSelectedDate: ({ date: selectedDate, omitUpdatingParams = false, preventMonthSwitching = false }) => { setSelectedDate: ({
date: selectedDate,
omitUpdatingParams = false,
preventMonthSwitching = false,
}) => {
// unset selected date // unset selected date
if (!selectedDate) { if (!selectedDate) {
removeQueryParam("date"); removeQueryParam("date");
@@ -247,15 +483,24 @@ export const createBookerStore = () =>
const currentSelection = dayjs(get().selectedDate); const currentSelection = dayjs(get().selectedDate);
const newSelection = dayjs(selectedDate); const newSelection = dayjs(selectedDate);
set({ selectedDate }); set({ selectedDate });
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) { if (
!omitUpdatingParams &&
(!get().isPlatform || get().allowUpdatingUrlParams)
) {
updateQueryParam("date", selectedDate ?? ""); updateQueryParam("date", selectedDate ?? "");
} }
// Setting month make sure small calendar in fullscreen layouts also updates. // Setting month make sure small calendar in fullscreen layouts also updates.
// preventMonthSwitching is true in monthly view // preventMonthSwitching is true in monthly view
if (!preventMonthSwitching && newSelection.month() !== currentSelection.month()) { if (
!preventMonthSwitching &&
newSelection.month() !== currentSelection.month()
) {
set({ month: newSelection.format("YYYY-MM") }); set({ month: newSelection.format("YYYY-MM") });
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) { if (
!omitUpdatingParams &&
(!get().isPlatform || get().allowUpdatingUrlParams)
) {
updateQueryParam("month", newSelection.format("YYYY-MM")); updateQueryParam("month", newSelection.format("YYYY-MM"));
} }
} }
@@ -316,7 +561,8 @@ export const createBookerStore = () =>
} }
get().setSelectedDate({ date: null }); 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) => { setDayCount: (dayCount: number | null) => {
set({ dayCount }); 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. // Preselect today's date in week / column view, since they use this to show the week title.
selectedDate: selectedDate:
selectedDateInStore || 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, teamMemberEmail,
crmOwnerRecordType, crmOwnerRecordType,
crmAppSlug, crmAppSlug,
@@ -459,14 +707,24 @@ export const createBookerStore = () =>
set({ rescheduleUid }); set({ rescheduleUid });
}, },
recurringEventCount: null, recurringEventCount: null,
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }), setRecurringEventCount: (recurringEventCount: number | null) =>
recurringEventCountQueryParam: Number(getQueryParam("recurringEventCount")) || null, set({ recurringEventCount }),
setRecurringEventCountQueryParam: (recurringEventCountQueryParam: number | null) => { recurringEventCountQueryParam:
Number(getQueryParam("recurringEventCount")) || null,
setRecurringEventCountQueryParam: (
recurringEventCountQueryParam: number | null
) => {
// Guard: only update state if value is valid (not NaN or 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 }); set({ recurringEventCountQueryParam });
if (!get().isPlatform || get().allowUpdatingUrlParams) { if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("recurringEventCount", recurringEventCountQueryParam); updateQueryParam(
"recurringEventCount",
recurringEventCountQueryParam
);
} }
} }
// If invalid, don't update state or URL - just ignore the call // If invalid, don't update state or URL - just ignore the call
@@ -496,6 +754,12 @@ export const createBookerStore = () =>
isPlatform: false, isPlatform: false,
allowUpdatingUrlParams: true, allowUpdatingUrlParams: true,
defaultPhoneCountry: null, defaultPhoneCountry: null,
isSlotSelectionModalVisible: false,
setIsSlotSelectionModalVisible: (
isSlotSelectionModalVisible: boolean
) => {
set({ isSlotSelectionModalVisible });
},
})); }));
/** /**
+89 -28
View File
@@ -17,6 +17,7 @@ import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip"; import { Tooltip } from "@calcom/ui/components/tooltip";
import NoAvailabilityDialog from "./NoAvailabilityDialog"; import NoAvailabilityDialog from "./NoAvailabilityDialog";
import { useSlotsViewOnSmallScreen } from "@calcom/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen";
export type DatePickerProps = { export type DatePickerProps = {
/** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */ /** 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 = ( const buttonContent = (
<button <button
type="button" type="button"
style={disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }} style={
disabled
? { ...disabledDateButtonEmbedStyles }
: { ...enabledDateButtonEmbedStyles }
}
className={classNames( 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 ", "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 active
@@ -97,7 +102,8 @@ const Day = ({
data-testid="day" data-testid="day"
data-disabled={disabled} data-disabled={disabled}
disabled={disabled} disabled={disabled}
{...props}> {...props}
>
{away && <span data-testid="away-emoji">{emoji}</span>} {away && <span data-testid="away-emoji">{emoji}</span>}
{!away && date.date()} {!away && date.date()}
{date.isToday() && ( {date.isToday() && (
@@ -105,7 +111,8 @@ const Day = ({
className={classNames( 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]", "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" active && "bg-brand-accent"
)}> )}
>
<span className="sr-only">{t("today")}</span> <span className="sr-only">{t("today")}</span>
</span> </span>
)} )}
@@ -131,7 +138,8 @@ const Day = ({
fontSize: "10px", fontSize: "10px",
lineHeight: "13px", lineHeight: "13px",
padding: disabled ? "0 3px" : "3px 3px 3px 4px", padding: disabled ? "0 3px" : "3px 3px 3px 4px",
}}> }}
>
{date.format("MMM")} {date.format("MMM")}
</div> </div>
)} )}
@@ -172,6 +180,10 @@ const Days = ({
periodData: PeriodData; periodData: PeriodData;
isCompact?: boolean; isCompact?: boolean;
}) => { }) => {
const slotsViewOnSmallScreen = useSlotsViewOnSmallScreen();
const layout = useBookerStoreContext((state) => state.layout);
const isMobile = layout === "mobile";
const includedDates = getAvailableDatesInMonth({ const includedDates = getAvailableDatesInMonth({
browsingDate: browsingDate.toDate(), browsingDate: browsingDate.toDate(),
minDate, minDate,
@@ -183,7 +195,8 @@ const Days = ({
const isSecondWeekOver = today.isAfter(firstDayOfMonth.add(2, "week")); const isSecondWeekOver = today.isAfter(firstDayOfMonth.add(2, "week"));
let days: (Dayjs | null)[] = []; 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 totalDays = daysInMonth(browsingDate);
const showNextMonthDays = isSecondWeekOver && !isCompact; 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) => { const isActive = (day: dayjs.Dayjs) => {
// for selecting a range of dates // for selecting a range of dates
if (Array.isArray(selected)) { 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)) { if (selected && yyyymmdd(selected) === yyyymmdd(day)) {
@@ -235,9 +254,11 @@ const Days = ({
selectedDatesAndTimes[eventSlug as string] && selectedDatesAndTimes[eventSlug as string] &&
Object.keys(selectedDatesAndTimes[eventSlug as string]).length > 0 Object.keys(selectedDatesAndTimes[eventSlug as string]).length > 0
) { ) {
return Object.keys(selectedDatesAndTimes[eventSlug as string]).some((date) => { return Object.keys(selectedDatesAndTimes[eventSlug as string]).some(
(date) => {
return yyyymmdd(dayjs(date)) === yyyymmdd(day); return yyyymmdd(dayjs(date)) === yyyymmdd(day);
}); }
);
} }
return false; return false;
@@ -251,18 +272,24 @@ const Days = ({
const oooInfo = daySlots.find((slot) => slot.away) || null; const oooInfo = daySlots.find((slot) => slot.away) || null;
const isNextMonth = day.month() !== browsingDate.month(); 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 included = includedDates?.includes(dateKey);
const excluded = excludedDates.includes(dateKey); const excluded = excludedDates.includes(dateKey);
const hasAvailableSlots = daySlots.some((slot) => !slot.away); 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; const away = isOOOAllDay;
// OOO dates are selectable only if there's a redirect user OR the note is public // OOO dates are selectable only if there's a redirect user OR the note is public
const oooIsSelectable = oooInfo?.toUser || oooInfo?.showNotePublicly; const oooIsSelectable = oooInfo?.toUser || oooInfo?.showNotePublicly;
const disabled = away ? !oooIsSelectable : isNextMonth ? !hasAvailableSlots : !included || excluded; const disabled = away
? !oooIsSelectable
: isNextMonth
? !hasAvailableSlots
: !included || excluded;
return { return {
day, day,
@@ -278,18 +305,24 @@ const Days = ({
*/ */
const useHandleInitialDateSelection = () => { 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 // 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) { if (selected instanceof Array) {
return; return;
} }
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day; const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find(
(day) => !day.disabled
)?.day;
const isSelectedDateAvailable = selected const isSelectedDateAvailable = selected
? daysToRenderForTheMonth.some(({ day, disabled }) => { ? daysToRenderForTheMonth.some(({ day, disabled }) => {
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true; if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled)
return true;
}) })
: false; : false;
if (!isSelectedDateAvailable && firstAvailableDateOfTheMonth) { if (!isSelectedDateAvailable && firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month // 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 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,15 +340,20 @@ const Days = ({
return ( return (
<> <>
{daysToRenderForTheMonth.map(({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => ( {daysToRenderForTheMonth.map(
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]"> ({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => (
<div
key={day === null ? `e-${idx}` : `day-${day.format()}`}
className="relative w-full pt-[100%]"
>
{day === null ? ( {day === null ? (
<div key={`e-${idx}`} /> <div key={`e-${idx}`} />
) : props.isLoading ? ( ) : props.isLoading ? (
<button <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" 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}`} key={`e-${idx}`}
disabled> disabled
>
<SkeletonText className="h-8 w-9" /> <SkeletonText className="h-8 w-9" />
</button> </button>
) : ( ) : (
@@ -333,12 +371,17 @@ const Days = ({
active={isActive(day)} active={isActive(day)}
away={away} away={away}
emoji={emoji} emoji={emoji}
showMonthTooltip={showNextMonthDays && !disabled && day.month() !== browsingDate.month()} showMonthTooltip={
showNextMonthDays &&
!disabled &&
day.month() !== browsingDate.month()
}
isFirstDayOfNextMonth={isFirstDayOfNextMonth} isFirstDayOfNextMonth={isFirstDayOfNextMonth}
/> />
)} )}
</div> </div>
))} )
)}
{!props.isLoading && {!props.isLoading &&
!isBookingInPast && !isBookingInPast &&
includedDates && includedDates &&
@@ -386,13 +429,18 @@ const DatePicker = ({
scrollToTimeSlots?: () => void; scrollToTimeSlots?: () => void;
}) => { }) => {
const minDate = passThroughProps.minDate; const minDate = passThroughProps.minDate;
const rawBrowsingDate = passThroughProps.browsingDate || dayjs().startOf("month"); const rawBrowsingDate =
passThroughProps.browsingDate || dayjs().startOf("month");
const browsingDate = const browsingDate =
minDate && rawBrowsingDate.valueOf() < minDate.valueOf() ? dayjs(minDate) : rawBrowsingDate; minDate && rawBrowsingDate.valueOf() < minDate.valueOf()
? dayjs(minDate)
: rawBrowsingDate;
const { i18n, t } = useLocale(); const { i18n, t } = useLocale();
const bookingData = useBookerStoreContext((state) => state.bookingData); 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) => { const changeMonth = (newMonth: number) => {
if (onMonthChange) { if (onMonthChange) {
onMonthChange(browsingDate.add(newMonth, "month")); onMonthChange(browsingDate.add(newMonth, "month"));
@@ -409,12 +457,24 @@ const DatePicker = ({
<div className="mb-1 flex items-center justify-between text-xl"> <div className="mb-1 flex items-center justify-between text-xl">
<span className="text-default w-1/2 text-base"> <span className="text-default w-1/2 text-base">
{browsingDate ? ( {browsingDate ? (
<time dateTime={browsingDate.format("YYYY-MM")} data-testid="selected-month-label"> <time
dateTime={browsingDate.format("YYYY-MM")}
data-testid="selected-month-label"
>
<strong <strong
className={classNames(`text-emphasis font-semibold`, customClassNames?.datePickerTitle)}> className={classNames(
`text-emphasis font-semibold`,
customClassNames?.datePickerTitle
)}
>
{month} {month}
</strong>{" "} </strong>{" "}
<span className={classNames(`text-subtle font-medium`, customClassNames?.datePickerTitle)}> <span
className={classNames(
`text-subtle font-medium`,
customClassNames?.datePickerTitle
)}
>
{browsingDate.format("YYYY")} {browsingDate.format("YYYY")}
</span> </span>
</time> </time>
@@ -461,7 +521,8 @@ const DatePicker = ({
className={classNames( className={classNames(
`text-emphasis my-4 text-xs font-medium uppercase tracking-widest`, `text-emphasis my-4 text-xs font-medium uppercase tracking-widest`,
customClassNames?.datePickerDays customClassNames?.datePickerDays
)}> )}
>
{weekDay} {weekDay}
</div> </div>
))} ))}
+1
View File
@@ -7,6 +7,7 @@ export type EmbedType = "inline" | "floating-popup" | "element-click" | "email"
type EmbedConfig = { type EmbedConfig = {
layout?: BookerLayouts; layout?: BookerLayouts;
theme?: Theme; theme?: Theme;
useSlotsViewOnSmallScreen?: "true" | "false";
}; };
export type EmbedState = { export type EmbedState = {
+18
View File
@@ -6,6 +6,24 @@ import createFetchMock from "vitest-fetch-mock";
import type { CalendarService } from "@calcom/types/Calendar"; import type { CalendarService } from "@calcom/types/Calendar";
global.ResizeObserver = ResizeObserver; 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); const fetchMocker = createFetchMock(vi);
// sets globalThis.fetch and globalThis.fetchMock to our mocked version // 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(), 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); expect.extend(matchers);
afterEach(() => { 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,
});