import { useEffect } from "react"; import { create } from "zustand"; import dayjs from "@calcom/dayjs"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import type { GetBookingType } from "../lib/get-booking"; import type { BookerState, BookerLayout } from "./types"; import { validateLayout } from "./utils/layout"; import { updateQueryParam, getQueryParam, removeQueryParam } from "./utils/query-param"; /** * Arguments passed into store initializer, containing * the event data. */ 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; rescheduleUid?: string | null; seatReferenceUid?: string; }; type SeatedEventData = { seatsPerTimeSlot?: number | null; attendees?: number; bookingUid?: string; }; 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; /** * 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: (date: string | null) => void; addToSelectedDate: (days: number) => void; /** * 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; /** * Number of recurring events to create. */ recurringEventCount: number | null; setRecurringEventCount(count: number | null): void; /** * If booking is being rescheduled or it has seats, it receives a rescheduleUid or bookingUid * the current booking details are passed in. The `bookingData` * object is something that's fetched server side. */ rescheduleUid: string | null; bookingUid: string | null; bookingData: GetBookingType | null; /** * 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. Get's cleared on submit * to prevent sticky data. */ formValues: Record; setFormValues: (values: Record) => 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; }; /** * The booker store contains the data of the component's * current state. This data can be reused within child components * by importing this hook. * * See comments in interface above for more information on it's specific values. */ export const useBookerStore = create((set, get) => ({ state: "loading", setState: (state: BookerState) => set({ state }), layout: validateLayout(getQueryParam("layout") as BookerLayouts) || 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") }); } return set({ layout }); }, selectedDate: getQueryParam("date") || null, setSelectedDate: (selectedDate: string | null) => { const currentSelection = dayjs(get().selectedDate); const newSelection = dayjs(selectedDate); set({ selectedDate }); updateQueryParam("date", selectedDate ?? ""); // Setting month make sure small calendar in fullscreen layouts also updates. if (newSelection.month() !== currentSelection.month()) { set({ month: newSelection.format("YYYY-MM") }); updateQueryParam("month", newSelection.format("YYYY-MM")); } }, addToSelectedDate: (days: number) => { const currentSelection = dayjs(get().selectedDate); const newSelection = currentSelection.add(days, "day"); const newSelectionFormatted = newSelection.format("YYYY-MM-DD"); if (newSelection.month() !== currentSelection.month()) { set({ month: newSelection.format("YYYY-MM") }); updateQueryParam("month", newSelection.format("YYYY-MM")); } set({ selectedDate: newSelectionFormatted }); updateQueryParam("date", newSelectionFormatted); }, username: null, eventSlug: null, eventId: null, month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"), setMonth: (month: string | null) => { set({ month, selectedTimeslot: null }); updateQueryParam("month", month ?? ""); get().setSelectedDate(null); }, isTeamEvent: false, seatedEventData: { seatsPerTimeSlot: undefined, attendees: undefined, bookingUid: undefined, }, setSeatedEventData: (seatedEventData: SeatedEventData) => { set({ seatedEventData }); updateQueryParam("bookingUid", seatedEventData.bookingUid ?? "null"); }, initialize: ({ username, eventSlug, month, eventId, rescheduleUid = null, bookingUid = null, bookingData = null, layout, isTeamEvent, }: 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 ) return; set({ username, eventSlug, eventId, rescheduleUid, bookingUid, bookingData, layout: layout || BookerLayouts.MONTH_VIEW, isTeamEvent: isTeamEvent || false, // 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), }); // 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 }); removeQueryParam("layout"); }, selectedDuration: Number(getQueryParam("duration")) || null, setSelectedDuration: (selectedDuration: number | null) => { set({ selectedDuration }); updateQueryParam("duration", selectedDuration ?? ""); }, recurringEventCount: null, setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }), rescheduleUid: null, bookingData: null, bookingUid: null, selectedTimeslot: getQueryParam("slot") || null, setSelectedTimeslot: (selectedTimeslot: string | null) => { set({ selectedTimeslot }); updateQueryParam("slot", selectedTimeslot ?? ""); }, formValues: {}, setFormValues: (formValues: Record) => { set({ formValues }); }, })); export const useInitializeBookerStore = ({ username, eventSlug, month, eventId, rescheduleUid = null, bookingData = null, layout, isTeamEvent, }: StoreInitializeType) => { const initializeStore = useBookerStore((state) => state.initialize); useEffect(() => { initializeStore({ username, eventSlug, month, eventId, rescheduleUid, bookingData, layout, isTeamEvent, }); }, [initializeStore, username, eventSlug, month, eventId, rescheduleUid, bookingData, layout, isTeamEvent]); };