Files
cal-diy-oidc/packages/features/bookings/Booker/store.ts
T
Rajiv Sahal 8c39210a12 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>
2026-01-21 15:44:33 -03:00

843 lines
20 KiB
TypeScript

"use client";
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";
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",
"om",
"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",
] as const;
export type CountryCode = (typeof _iso_3166_1_alpha_2_codes)[number];
/**
* Arguments passed into store initializer, containing
* the event data.
*/
export type StoreInitializeType = {
username: string;
eventSlug: string;
// Month can be undefined if it's not passed in as a prop.
eventId: number | undefined;
layout: BookerLayout;
month?: string;
bookingUid?: string | null;
isTeamEvent?: boolean;
bookingData?: GetBookingType | null | undefined;
verifiedEmail?: string | null;
rescheduleUid?: string | null;
rescheduledBy?: string | null;
seatReferenceUid?: string;
durationConfig?: number[] | null;
org?: string | null;
isInstantMeeting?: boolean;
timezone?: string | null;
teamMemberEmail?: string | null;
crmOwnerRecordType?: string | null;
crmAppSlug?: string | null;
crmRecordId?: string | null;
isPlatform?: boolean;
allowUpdatingUrlParams?: boolean;
defaultPhoneCountry?: CountryCode;
};
type SeatedEventData = {
seatsPerTimeSlot?: number | null;
attendees?: number;
bookingUid?: string;
showAvailableSeatsCount?: boolean | null;
};
export type BookerStore = {
/**
* Event details. These are stored in store for easier
* access in child components.
*/
username: string | null;
eventSlug: string | null;
eventId: number | null;
/**
* Verified booker email.
* Needed in case user turns on Requires Booker Email Verification for an event
*/
verifiedEmail: string | null;
setVerifiedEmail: (email: string | null) => void;
/**
* Verification code for email verification.
* Stored after successful verification to be included in booking request
*/
verificationCode: string | null;
setVerificationCode: (code: string | null) => void;
/**
* Current month being viewed. Format is YYYY-MM.
*/
month: string | null;
setMonth: (month: string | null) => void;
/**
* Current state of the booking process
* the user is currently in. See enum for possible values.
*/
state: BookerState;
setState: (state: BookerState) => void;
/**
* The booker component supports different layouts,
* this value tracks the current layout.
*/
layout: BookerLayout;
setLayout: (layout: BookerLayout) => void;
/**
* Date selected by user (exact day). Format is YYYY-MM-DD.
*/
selectedDate: string | null;
setSelectedDate: (params: {
date: string | null;
omitUpdatingParams?: boolean;
preventMonthSwitching?: boolean;
}) => void;
addToSelectedDate: (days: number) => void;
/**
* Multiple Selected Dates and Times
*/
selectedDatesAndTimes: { [key: string]: { [key: string]: string[] } } | null;
setSelectedDatesAndTimes: (selectedDatesAndTimes: {
[key: string]: { [key: string]: string[] };
}) => void;
/**
* Multiple duration configuration
*/
durationConfig: number[] | null;
/**
* Selected event duration in minutes.
*/
selectedDuration: number | null;
setSelectedDuration: (duration: number | null) => void;
/**
* Selected timeslot user has chosen. This is a date string
* containing both the date + time.
*/
selectedTimeslot: string | null;
setSelectedTimeslot: (timeslot: string | null) => void;
tentativeSelectedTimeslots: string[];
setTentativeSelectedTimeslots: (slots: string[]) => void;
/**
* Number of recurring events to create.
*/
recurringEventCount: number | null;
setRecurringEventCount(count: number | null): void;
/**
* Input occurrence count.
*/
recurringEventCountQueryParam: number | null;
setRecurringEventCountQueryParam(count: number | null): void;
/**
* The number of days worth of schedules to load.
*/
dayCount: number | null;
setDayCount: (dayCount: number | null) => void;
/**
* If booking is being rescheduled or it has seats, it receives a rescheduleUid with rescheduledBy or bookingUid
* the current booking details are passed in. The `bookingData`
* object is something that's fetched server side.
*/
rescheduleUid: string | null;
setRescheduleUid: (rescheduleUid: string | null) => void;
rescheduledBy: string | null;
bookingUid: string | null;
bookingData: GetBookingType | null;
setBookingData: (bookingData: GetBookingType | null | undefined) => void;
/**
* Method called by booker component to set initial data.
*/
initialize: (data: StoreInitializeType) => void;
/**
* Stored form state, used when user navigates back and
* forth between timeslots and form. Gets cleared on submit
* to prevent sticky data.
*/
formValues: Record<string, any>;
setFormValues: (values: Record<string, any>) => void;
/**
* Force event being a team event, so we only query for team events instead
* of also include 'user' events and return the first event that matches with
* both the slug and the event slug.
*/
isTeamEvent: boolean;
seatedEventData: SeatedEventData;
setSeatedEventData: (seatedEventData: SeatedEventData) => void;
isInstantMeeting?: boolean;
org?: string | null;
setOrg: (org: string | null | undefined) => void;
timezone: string | null;
setTimezone: (timezone: string | null) => void;
teamMemberEmail?: string | null;
crmOwnerRecordType?: string | null;
crmAppSlug?: string | null;
crmRecordId?: string | null;
isPlatform?: boolean;
allowUpdatingUrlParams?: boolean;
defaultPhoneCountry?: CountryCode | null;
/**
* Whether the two-step slot selection modal/dialog is visible
*/
isSlotSelectionModalVisible: boolean;
setIsSlotSelectionModalVisible: (visible: boolean) => void;
};
/**
* Creates a new booker store instance
*/
export const createBookerStore = () =>
createWithEqualityFn<BookerStore>((set, get) => ({
state: "loading",
setState: (state: BookerState) => set({ state }),
layout: BookerLayouts.MONTH_VIEW,
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
) {
set({ selectedDate: dayjs().format("YYYY-MM-DD") });
}
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("layout", layout);
}
return set({ layout });
},
selectedDate: getQueryParam("date") || null,
setSelectedDate: ({
date: selectedDate,
omitUpdatingParams = false,
preventMonthSwitching = false,
}) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
return;
}
const currentSelection = dayjs(get().selectedDate);
const newSelection = dayjs(selectedDate);
set({ selectedDate });
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()
) {
set({ month: newSelection.format("YYYY-MM") });
if (
!omitUpdatingParams &&
(!get().isPlatform || get().allowUpdatingUrlParams)
) {
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
}
},
selectedDatesAndTimes: null,
setSelectedDatesAndTimes: (selectedDatesAndTimes) => {
set({ selectedDatesAndTimes });
},
addToSelectedDate: (days: number) => {
const currentSelection = dayjs(get().selectedDate);
let newSelection = currentSelection.add(days, "day");
// If newSelection is before the current date, set it to today
if (newSelection.isBefore(dayjs(), "day")) {
newSelection = dayjs();
}
const newSelectionFormatted = newSelection.format("YYYY-MM-DD");
if (newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
}
set({ selectedDate: newSelectionFormatted });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("date", newSelectionFormatted);
}
},
username: null,
eventSlug: null,
eventId: null,
rescheduledBy: null,
verifiedEmail: null,
setVerifiedEmail: (email: string | null) => {
set({ verifiedEmail: email });
},
verificationCode: null,
setVerificationCode: (code: string | null) => {
set({ verificationCode: code });
},
month:
getQueryParam("month") ||
(getQueryParam("date") && dayjs(getQueryParam("date")).isValid()
? dayjs(getQueryParam("date")).format("YYYY-MM")
: null) ||
dayjs().format("YYYY-MM"),
setMonth: (month: string | null) => {
if (!month) {
removeQueryParam("month");
return;
}
set({ month, selectedTimeslot: null });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("month", month ?? "");
}
get().setSelectedDate({ date: null });
},
dayCount:
BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
setDayCount: (dayCount: number | null) => {
set({ dayCount });
},
isTeamEvent: false,
seatedEventData: {
seatsPerTimeSlot: undefined,
attendees: undefined,
bookingUid: undefined,
showAvailableSeatsCount: true,
},
setSeatedEventData: (seatedEventData: SeatedEventData) => {
set({ seatedEventData });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("bookingUid", seatedEventData.bookingUid ?? "null");
}
},
// This is different from timeZone in timePreferencesStore, because timeZone in timePreferencesStore is the preferred timezone of the booker,
// it is the timezone configured through query param. So, this is in a way the preference of the person who shared the link.
// it is the timezone configured through query param. So, this is in a way the preference of the person who shared the link.
timezone: getQueryParam("cal.tz") ?? null,
setTimezone: (timezone: string | null) => {
set({ timezone });
},
initialize: ({
username,
eventSlug,
month,
eventId,
rescheduleUid = null,
rescheduledBy = null,
bookingUid = null,
bookingData = null,
layout,
isTeamEvent,
durationConfig,
org,
isInstantMeeting,
timezone = null,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform = false,
allowUpdatingUrlParams = true,
defaultPhoneCountry,
}: StoreInitializeType) => {
const selectedDateInStore = get().selectedDate;
if (
get().username === username &&
get().eventSlug === eventSlug &&
get().month === month &&
get().eventId === eventId &&
get().rescheduleUid === rescheduleUid &&
get().bookingUid === bookingUid &&
get().bookingData?.responses.email === bookingData?.responses.email &&
get().layout === layout &&
get().timezone === timezone &&
get().rescheduledBy === rescheduledBy &&
get().teamMemberEmail === teamMemberEmail &&
get().crmOwnerRecordType === crmOwnerRecordType &&
get().crmAppSlug === crmAppSlug &&
get().crmRecordId === crmRecordId
)
return;
set({
username,
eventSlug,
eventId,
org,
rescheduleUid,
rescheduledBy,
bookingUid,
bookingData,
layout: layout || BookerLayouts.MONTH_VIEW,
isTeamEvent: isTeamEvent || false,
durationConfig,
timezone,
// 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),
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform,
allowUpdatingUrlParams,
defaultPhoneCountry,
});
if (durationConfig?.includes(Number(getQueryParam("duration")))) {
set({
selectedDuration: Number(getQueryParam("duration")),
});
} else {
removeQueryParam("duration");
}
// Unset selected timeslot if user is rescheduling. This could happen
// if the user reschedules a booking right after the confirmation page.
// In that case the time would still be store in the store, this way we
// force clear this.
if (rescheduleUid && bookingData) {
set({ selectedTimeslot: null });
}
if (month) set({ month });
if (isInstantMeeting) {
const month = dayjs().format("YYYY-MM");
const selectedDate = dayjs().format("YYYY-MM-DD");
const selectedTimeslot = new Date().toISOString();
set({
month,
selectedDate,
selectedTimeslot,
isInstantMeeting,
});
if (!isPlatform || allowUpdatingUrlParams) {
updateQueryParam("month", month);
updateQueryParam("date", selectedDate ?? "");
updateQueryParam("slot", selectedTimeslot ?? "", false);
}
}
//removeQueryParam("layout");
},
durationConfig: null,
selectedDuration: null,
setSelectedDuration: (selectedDuration: number | null) => {
set({ selectedDuration });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("duration", selectedDuration ?? "");
}
},
setBookingData: (bookingData: GetBookingType | null | undefined) => {
set({ bookingData: bookingData ?? null });
},
setRescheduleUid: (rescheduleUid: string | null) => {
set({ rescheduleUid });
},
recurringEventCount: 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)
) {
set({ recurringEventCountQueryParam });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam(
"recurringEventCount",
recurringEventCountQueryParam
);
}
}
// If invalid, don't update state or URL - just ignore the call
},
rescheduleUid: null,
bookingData: null,
bookingUid: null,
selectedTimeslot: getQueryParam("slot") || null,
tentativeSelectedTimeslots: [],
setTentativeSelectedTimeslots: (tentativeSelectedTimeslots: string[]) => {
set({ tentativeSelectedTimeslots });
},
setSelectedTimeslot: (selectedTimeslot: string | null) => {
set({ selectedTimeslot });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("slot", selectedTimeslot ?? "", false);
}
},
formValues: {},
setFormValues: (formValues: Record<string, any>) => {
set({ formValues });
},
org: null,
setOrg: (org: string | null | undefined) => {
set({ org });
},
isPlatform: false,
allowUpdatingUrlParams: true,
defaultPhoneCountry: null,
isSlotSelectionModalVisible: false,
setIsSlotSelectionModalVisible: (
isSlotSelectionModalVisible: boolean
) => {
set({ isSlotSelectionModalVisible });
},
}));
/**
* Default global store instance for backward compatibility
*/
export const useBookerStore = createBookerStore();
export const useInitializeBookerStore = ({
username,
eventSlug,
month,
eventId,
rescheduleUid = null,
rescheduledBy = null,
bookingData = null,
verifiedEmail = null,
layout,
isTeamEvent,
durationConfig,
org,
isInstantMeeting,
timezone = null,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform = false,
allowUpdatingUrlParams = true,
defaultPhoneCountry,
}: StoreInitializeType) => {
const initializeStore = useBookerStore((state) => state.initialize);
useEffect(() => {
initializeStore({
username,
eventSlug,
month,
eventId,
rescheduleUid,
rescheduledBy,
bookingData,
layout,
isTeamEvent,
org,
verifiedEmail,
durationConfig,
isInstantMeeting,
timezone,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform,
allowUpdatingUrlParams,
defaultPhoneCountry,
});
}, [
initializeStore,
org,
username,
eventSlug,
month,
eventId,
rescheduleUid,
rescheduledBy,
bookingData,
layout,
isTeamEvent,
verifiedEmail,
durationConfig,
isInstantMeeting,
timezone,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform,
allowUpdatingUrlParams,
defaultPhoneCountry,
]);
};