diff --git a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx index cd85a4738a..8bbb13b4cb 100644 --- a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx +++ b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx @@ -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 | 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(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 ( <> -
+
{isLoading ? (
) : ( @@ -197,15 +229,19 @@ export const AvailableTimeSlots = ({ return ( + )} + > {isLoading && // Shows exact amount of days as skeleton. - Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => )} + Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => ( + + ))} {!isLoading && slotsPerDay.length > 0 && slotsPerDay.map((slots) => ( -
+
{ + const actual = await importOriginal(); + 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(, { - mockStore: { state: "loading" }, - }); + const { container } = render( + , + { + mockStore: { state: "loading" }, + } + ); expect(container).toBeEmptyDOMElement(); }); @@ -194,7 +215,7 @@ describe("Booker", () => { }, }; - render(, { + render(, { mockStore: { state: "selecting_time", selectedDate: "2024-01-01", @@ -218,7 +239,7 @@ describe("Booker", () => { }, }; - render(, { + render(, { mockStore: { state: "booking" }, }); screen.logTestingPlaygroundURL(); @@ -240,11 +261,11 @@ describe("Booker", () => { }, }; - render(, { + render(, { mockStore: { state: "booking" }, }); const bookEventForm = screen.getByTestId("book-event-form"); await expect(bookEventForm).toHaveAttribute("data-unavailable", "true"); }); }); -}); \ No newline at end of file +}); diff --git a/apps/web/modules/bookings/components/Booker.tsx b/apps/web/modules/bookings/components/Booker.tsx index e6201fc384..2ba1e1dd83 100644 --- a/apps/web/modules/bookings/components/Booker.tsx +++ b/apps/web/modules/bookings/components/Booker.tsx @@ -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); + } + }} />
)} @@ -493,7 +520,7 @@ const BookerComponent = ({ + {EventBooker} + + + setIsSlotSelectionModalVisible(false)} + event={event.data} + isPlatform={isPlatform} + timeZones={timeZones} + selectedDate={selectedDate} + /> + + + ); diff --git a/apps/web/modules/bookings/components/BookerWebWrapper.tsx b/apps/web/modules/bookings/components/BookerWebWrapper.tsx index 95b74f43e3..38da38b114 100644 --- a/apps/web/modules/bookings/components/BookerWebWrapper.tsx +++ b/apps/web/modules/bookings/components/BookerWebWrapper.tsx @@ -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(() => { diff --git a/apps/web/modules/bookings/components/DatePicker.tsx b/apps/web/modules/bookings/components/DatePicker.tsx index 7c35dc14a4..d2d4193a93 100644 --- a/apps/web/modules/bookings/components/DatePicker.tsx +++ b/apps/web/modules/bookings/components/DatePicker.tsx @@ -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} diff --git a/apps/web/modules/bookings/components/SlotSelectionModalHeader.tsx b/apps/web/modules/bookings/components/SlotSelectionModalHeader.tsx new file mode 100644 index 0000000000..2d411c1a71 --- /dev/null +++ b/apps/web/modules/bookings/components/SlotSelectionModalHeader.tsx @@ -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 {t("loading")}; +}; + +const WebTimezoneSelect = dynamic( + () => + import("@calcom/features/components/timezone-select").then( + (mod) => mod.TimezoneSelect + ), + { + ssr: false, + loading: () => , + } +); + +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 ( +
+
+
+ +
+ + {event && ( + + )} + +
+ + {TimezoneSelect && ( + + + "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} + /> + + )} +
+
+
+ ); +}; diff --git a/apps/web/modules/embed/components/Embed.tsx b/apps/web/modules/embed/components/Embed.tsx index 0c69585429..837f01c66c 100644 --- a/apps/web/modules/embed/components/Embed.tsx +++ b/apps/web/modules/embed/components/Embed.tsx @@ -228,7 +228,9 @@ const ChooseEmbedTypesDialogContent = ({ return (
-
@@ -864,6 +866,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({ useState(true); const defaultConfig = { layout: BookerLayouts.MONTH_VIEW, + useSlotsViewOnSmallScreen: "true" as const, }; const paletteDefaultValue = (paletteName: string) => { diff --git a/apps/web/modules/test-setup.ts b/apps/web/modules/test-setup.ts index e035fbad27..8feed7b67e 100644 --- a/apps/web/modules/test-setup.ts +++ b/apps/web/modules/test-setup.ts @@ -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(); }); diff --git a/packages/embeds/README.md b/packages/embeds/README.md index e0e9f885f9..23b4180ba9 100644 --- a/packages/embeds/README.md +++ b/packages/embeds/README.md @@ -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 -``` \ No newline at end of file +``` diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 4da78e367a..b6b0392d3f 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -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 @@
+
+
+

Test Two Step Slot Selection

+ 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. +
+
+
+

Skeleton Loader INLINE Demo

@@ -505,10 +513,33 @@
- \ No newline at end of file + diff --git a/packages/embeds/embed-core/playground/lib/playground.ts b/packages/embeds/embed-core/playground/lib/playground.ts index 6d9ce279de..e77a8b37aa 100644 --- a/packages/embeds/embed-core/playground/lib/playground.ts +++ b/packages/embeds/embed-core/playground/lib/playground.ts @@ -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", diff --git a/packages/embeds/embed-core/playwright/tests/embed-pages.e2e.ts b/packages/embeds/embed-core/playwright/tests/embed-pages.e2e.ts index 8c50488ca2..8d8c359654 100644 --- a/packages/embeds/embed-core/playwright/tests/embed-pages.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/embed-pages.e2e.ts @@ -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); + }); + }); }); diff --git a/packages/embeds/embed-core/playwright/tests/two-step-slot-selection.e2e.ts b/packages/embeds/embed-core/playwright/tests/two-step-slot-selection.e2e.ts new file mode 100644 index 0000000000..e2b84a29a0 --- /dev/null +++ b/packages/embeds/embed-core/playwright/tests/two-step-slot-selection.e2e.ts @@ -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(); + }); +}); diff --git a/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts b/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts index 3e5a3b659a..bfb7d487b5 100644 --- a/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts +++ b/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts @@ -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); + }); + }); }); diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index c97d1069cb..43748ded6c 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -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({}); 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>; + mergedCssVarsPerTheme = {} as Record< + "light" | "dark", + Record + >; 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; toRemoveParams: string[]; }) { - const { hasChanged, stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({ - toBeThereParams, - toRemoveParams, - }); + const { hasChanged, stopEnsuringQueryParamsInUrl } = + embedStore.router.ensureQueryParamsInUrl({ + toBeThereParams, + toRemoveParams, + }); let waitForFrames = 0; diff --git a/packages/embeds/embed-core/src/types.ts b/packages/embeds/embed-core/src/types.ts index fc5b42bf9c..be1791afec 100644 --- a/packages/embeds/embed-core/src/types.ts +++ b/packages/embeds/embed-core/src/types.ts @@ -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 = diff --git a/packages/embeds/embed-react/element-click.tsx b/packages/embeds/embed-react/element-click.tsx index b58710ba72..92349cbfd2 100644 --- a/packages/embeds/embed-react/element-click.tsx +++ b/packages/embeds/embed-react/element-click.tsx @@ -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 ( ); diff --git a/packages/embeds/embed-react/inline.tsx b/packages/embeds/embed-react/inline.tsx index b07529d85b..183f8f91a4 100644 --- a/packages/embeds/embed-react/inline.tsx +++ b/packages/embeds/embed-react/inline.tsx @@ -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", }} /> diff --git a/packages/features/bookings/Booker/__tests__/test-utils.tsx b/packages/features/bookings/Booker/__tests__/test-utils.tsx index a22cd24a4b..dae62963c3 100644 --- a/packages/features/bookings/Booker/__tests__/test-utils.tsx +++ b/packages/features/bookings/Booker/__tests__/test-utils.tsx @@ -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): StoreApi => { const mockStore = createMockStore(options?.mockStore); - const Wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => ( + const Wrapper = ({ children }: { children: ReactNode }): ReactElement => ( {children} ); diff --git a/packages/features/bookings/Booker/components/hooks/useBookerLayout.ts b/packages/features/bookings/Booker/components/hooks/useBookerLayout.ts index 9694379aee..3740dde433 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookerLayout.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookerLayout.ts @@ -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; 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, }; }; diff --git a/packages/features/bookings/Booker/components/hooks/useInitializeWeekStart.ts b/packages/features/bookings/Booker/components/hooks/useInitializeWeekStart.ts index c2d3a38b22..d96b6f5d5f 100644 --- a/packages/features/bookings/Booker/components/hooks/useInitializeWeekStart.ts +++ b/packages/features/bookings/Booker/components/hooks/useInitializeWeekStart.ts @@ -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 }); } diff --git a/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts b/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts index 3218dc4e55..4535a903ed 100644 --- a/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts +++ b/packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts @@ -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"; diff --git a/packages/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen.ts b/packages/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen.ts new file mode 100644 index 0000000000..dbfea3b1bb --- /dev/null +++ b/packages/features/bookings/Booker/components/hooks/useSlotsViewOnSmallScreen.ts @@ -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; +}; diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index c6f2627a58..4f45f258ae 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -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 }); + }, })); /** diff --git a/packages/features/calendars/DatePicker.tsx b/packages/features/calendars/DatePicker.tsx index fc0de3b067..95d7776a86 100644 --- a/packages/features/calendars/DatePicker.tsx +++ b/packages/features/calendars/DatePicker.tsx @@ -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 = (
)} @@ -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) => ( -
- {day === null ? ( -
- ) : props.isLoading ? ( - - ) : ( - { - props.onChange(day); - props?.scrollToTimeSlots?.(); - }} - disabled={disabled} - active={isActive(day)} - away={away} - emoji={emoji} - showMonthTooltip={showNextMonthDays && !disabled && day.month() !== browsingDate.month()} - isFirstDayOfNextMonth={isFirstDayOfNextMonth} - /> - )} -
- ))} + {daysToRenderForTheMonth.map( + ({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => ( +
+ {day === null ? ( +
+ ) : props.isLoading ? ( + + ) : ( + { + props.onChange(day); + props?.scrollToTimeSlots?.(); + }} + disabled={disabled} + active={isActive(day)} + away={away} + emoji={emoji} + showMonthTooltip={ + showNextMonthDays && + !disabled && + day.month() !== browsingDate.month() + } + isFirstDayOfNextMonth={isFirstDayOfNextMonth} + /> + )} +
+ ) + )} {!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 = ({
{browsingDate ? ( -
))} diff --git a/packages/features/embed/types/index.d.ts b/packages/features/embed/types/index.d.ts index f4f60709af..b567d6c9b8 100644 --- a/packages/features/embed/types/index.d.ts +++ b/packages/features/embed/types/index.d.ts @@ -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 = { diff --git a/packages/testing/src/setupVitest.ts b/packages/testing/src/setupVitest.ts index 8ce1bb5c2f..1e7aea5787 100644 --- a/packages/testing/src/setupVitest.ts +++ b/packages/testing/src/setupVitest.ts @@ -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 diff --git a/packages/ui/components/test-setup.tsx b/packages/ui/components/test-setup.tsx index 95ed0b4700..57b4069887 100644 --- a/packages/ui/components/test-setup.tsx +++ b/packages/ui/components/test-setup.tsx @@ -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(() => { diff --git a/setupVitest.ts b/setupVitest.ts new file mode 100644 index 0000000000..f0622c041d --- /dev/null +++ b/setupVitest.ts @@ -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, +});