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:
co-authored by
rajiv@cal.com
Devin AI
Peer Richelsen
Hariom Balhara
parent
6c620e37a6
commit
8c39210a12
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user