fix: add recurringEventCount to URL params (#24986)

* fix: validate occurrence count from URL params and refactor validation logic
- Initialize occurrence count from URL query parameter on page load

* fix: change query parameter name from occurenceCount to recurringEventCount

* refactor: simplify occurrence count validation logic

* fix: prevent NaN from being set in recurring event count query parameter

* fix: prevent overlay calendar toggle from overwriting query params

* fix: sync occurrence count state with max occurrences limit

* refactor: rename occurenceCount to recurringEventCountQueryParam

* fix: prevent invalid recurring event count from updating state

- Added validation guard to only update state when recurringEventCountQueryParam is valid (not null or NaN)
- Simplified URL update logic by removing unnecessary empty string fallback

* feat: add recurring event count badge for mobile (#24991)

* feat: display recurring event count badge in mobile

* feat: update recurring event translation key for clarity
This commit is contained in:
chauhan_s
2025-11-12 09:12:49 +00:00
committed by GitHub
parent 421d03eb1a
commit 6af7f8b0db
7 changed files with 75 additions and 46 deletions
@@ -3969,6 +3969,7 @@
"slots_taken": "{{takenSeats}}/{{totalSeats}} slots taken",
"team_invite_subtitle": "Invite team members to collaborate and schedule together",
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
"repeats_num_times": "Repeats {{count}} times",
"view_booking_details": "View Booking Details",
"view": "View",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
@@ -42,8 +42,8 @@ const createMockStore = (initialState?: Partial<BookerStore>): StoreApi<BookerSt
setTentativeSelectedTimeslots: vi.fn(),
recurringEventCount: null,
setRecurringEventCount: vi.fn(),
occurenceCount: null,
setOccurenceCount: vi.fn(),
recurringEventCountQueryParam: null,
setRecurringEventCountQueryParam: vi.fn(),
dayCount: null,
setDayCount: vi.fn(),
rescheduleUid: null,
@@ -46,6 +46,7 @@ export const BookEventFormWrapperComponent = ({
const { i18n, t } = useLocale();
const selectedTimeslot = useBookerStoreContext((state) => state.selectedTimeslot);
const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const recurringEventCount = useBookerStoreContext((state) => state.recurringEventCount);
const { timeFormat, timezone } = useBookerTime();
if (!selectedTimeslot) {
return null;
@@ -67,6 +68,16 @@ export const BookEventFormWrapperComponent = ({
<span>{getDurationFormatted(selectedDuration || eventLength, t)}</span>
</Badge>
)}
{recurringEventCount && recurringEventCount > 1 && (
<Badge variant="grayWithoutHover" startIcon="refresh-ccw" size="lg">
<span>
{t("repeats_num_times", {
count: recurringEventCount,
})}
</span>
</Badge>
)}
</div>
{child}
</>
+19 -5
View File
@@ -3,14 +3,19 @@
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";
/**
* Arguments passed into store initializer, containing
* the event data.
@@ -125,8 +130,8 @@ export type BookerStore = {
/**
* Input occurrence count.
*/
occurenceCount: number | null;
setOccurenceCount(count: number | null): void;
recurringEventCountQueryParam: number | null;
setRecurringEventCountQueryParam(count: number | null): void;
/**
* The number of days worth of schedules to load.
*/
@@ -421,8 +426,17 @@ export const createBookerStore = () =>
},
recurringEventCount: null,
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }),
occurenceCount: null,
setOccurenceCount: (occurenceCount: number | null) => set({ occurenceCount }),
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,
@@ -523,4 +537,4 @@ export const useInitializeBookerStore = ({
isPlatform,
allowUpdatingUrlParams,
]);
};
};
@@ -14,25 +14,42 @@ import { useBookerTime } from "../../Booker/components/hooks/useBookerTime";
export const EventOccurences = ({ event }: { event: Pick<BookerEvent, "recurringEvent"> }) => {
const maxOccurences = event.recurringEvent?.count || null;
const { t, i18n } = useLocale();
const [setRecurringEventCount, recurringEventCount, setOccurenceCount, occurenceCount] =
const [setRecurringEventCount, recurringEventCount, setRecurringEventCountQueryParam, recurringEventCountQueryParam] =
useBookerStoreContext((state) => [
state.setRecurringEventCount,
state.recurringEventCount,
state.setOccurenceCount,
state.occurenceCount,
state.setRecurringEventCountQueryParam,
state.recurringEventCountQueryParam,
]);
const selectedTimeslot = useBookerStoreContext((state) => state.selectedTimeslot);
const bookerState = useBookerStoreContext((state) => state.state);
const { timezone, timeFormat } = useBookerTime();
const [warning, setWarning] = useState(false);
// Set initial value in booker store.
const validateAndSetRecurringEventCount = (value: number | string) => {
const inputValue = parseInt(value as string);
const isValid =
!isNaN(inputValue) && inputValue >= 1 && maxOccurences !== null && inputValue <= maxOccurences;
if (isValid) {
setRecurringEventCount(inputValue);
setWarning(false);
} else {
setRecurringEventCount(maxOccurences);
setWarning(true);
}
};
useEffect(() => {
if (!event.recurringEvent?.count) return;
setOccurenceCount(occurenceCount || event.recurringEvent.count);
setRecurringEventCount(recurringEventCount || event.recurringEvent.count);
if (occurenceCount && (occurenceCount > event.recurringEvent.count || occurenceCount < 1))
setWarning(true);
}, [setRecurringEventCount, event.recurringEvent, recurringEventCount, setOccurenceCount, occurenceCount]);
if (recurringEventCountQueryParam) {
validateAndSetRecurringEventCount(recurringEventCountQueryParam);
} else {
setRecurringEventCount(maxOccurences);
setRecurringEventCountQueryParam(maxOccurences);
}
}, [setRecurringEventCount, event.recurringEvent, recurringEventCount, recurringEventCountQueryParam]);
if (!event.recurringEvent) return null;
if (bookerState === "booking" && recurringEventCount && selectedTimeslot) {
@@ -72,23 +89,12 @@ export const EventOccurences = ({ event }: { event: Pick<BookerEvent, "recurring
type="number"
min="1"
max={event.recurringEvent.count}
defaultValue={occurenceCount || event.recurringEvent.count}
defaultValue={recurringEventCountQueryParam || event.recurringEvent.count}
data-testid="occurrence-input"
onChange={(event) => {
const pattern = /^(?=.*[0-9])\S+$/;
const inputValue = parseInt(event.target.value);
setOccurenceCount(inputValue);
if (
!pattern.test(event.target.value) ||
inputValue < 1 ||
(maxOccurences && inputValue > maxOccurences)
) {
setWarning(true);
setRecurringEventCount(maxOccurences);
} else {
setWarning(false);
setRecurringEventCount(inputValue);
}
setRecurringEventCountQueryParam(inputValue);
validateAndSetRecurringEventCount(event.target.value);
}}
/>
@@ -185,20 +185,17 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => {
// Toggle query param for overlay calendar
const onOverlaySwitchStateChange = useCallback(
(state: boolean) => {
const current = new URLSearchParams(Array.from(searchParams?.entries() ?? []));
const url = new URL(window.location.href);
if (state) {
current.set("overlayCalendar", "true");
url.searchParams.set("overlayCalendar", "true");
localStorage.setItem("overlayCalendarSwitchDefault", "true");
} else {
current.delete("overlayCalendar");
url.searchParams.delete("overlayCalendar");
localStorage.removeItem("overlayCalendarSwitchDefault");
}
// cast to string
const value = current.toString();
const query = value ? `?${value}` : "";
router.push(`${pathname}${query}`);
router.push(`${url.pathname}${url.search}`);
},
[searchParams, pathname, router]
[router]
);
useBrandColors({
brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
+8 -8
View File
@@ -1,20 +1,20 @@
import type React from "react";
import type { BookerProps } from "@calcom/features/bookings/Booker";
import type { BookerStore } from "@calcom/features/bookings/Booker/store";
import type { Timezone, VIEW_TYPE } from "@calcom/features/bookings/Booker/types";
import type { BookingCreateBody } from "@calcom/features/bookings/lib/bookingCreateBodySchema";
import type { BookingResponse } from "@calcom/platform-libraries";
import type {
ApiSuccessResponse,
ApiErrorResponse,
ApiSuccessResponseWithoutData,
RoutingFormSearchParams,
} from "@calcom/platform-types";
import type { ApiSuccessResponse, ApiErrorResponse, ApiSuccessResponseWithoutData, RoutingFormSearchParams } from "@calcom/platform-types";
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
import type { UseCreateBookingInput } from "../hooks/bookings/useCreateBooking";
// Type that includes only the data values from BookerStore (excluding functions)
export type BookerStoreValues = Omit<
BookerStore,
@@ -32,7 +32,7 @@ export type BookerStoreValues = Omit<
| "setSelectedDuration"
| "setBookingData"
| "setRecurringEventCount"
| "setOccurenceCount"
| "setRecurringEventCountQueryParam"
| "setTentativeSelectedTimeslots"
| "setSelectedTimeslot"
| "setFormValues"
@@ -101,4 +101,4 @@ export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomPro
isTeamEvent: true;
teamId: number;
routingFormSearchParams?: RoutingFormSearchParams;
};
};