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