From 6af7f8b0dbfbec6892ce94ed3846dfe907267642 Mon Sep 17 00:00:00 2001 From: chauhan_s Date: Wed, 12 Nov 2025 14:42:49 +0530 Subject: [PATCH] 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 --- apps/web/public/static/locales/en/common.json | 1 + .../bookings/Booker/__tests__/test-utils.tsx | 4 +- .../BookEventForm/BookFormAsModal.tsx | 11 ++++ packages/features/bookings/Booker/store.ts | 24 +++++++-- .../components/event-meta/Occurences.tsx | 52 +++++++++++-------- .../atoms/booker/BookerWebWrapper.tsx | 13 ++--- packages/platform/atoms/booker/types.ts | 16 +++--- 7 files changed, 75 insertions(+), 46 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b6e675ade1..cfb3d04cd7 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/features/bookings/Booker/__tests__/test-utils.tsx b/packages/features/bookings/Booker/__tests__/test-utils.tsx index f0bde6342a..46d0cd207a 100644 --- a/packages/features/bookings/Booker/__tests__/test-utils.tsx +++ b/packages/features/bookings/Booker/__tests__/test-utils.tsx @@ -42,8 +42,8 @@ const createMockStore = (initialState?: Partial): StoreApi 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 = ({ {getDurationFormatted(selectedDuration || eventLength, t)} )} + + {recurringEventCount && recurringEventCount > 1 && ( + + + {t("repeats_num_times", { + count: recurringEventCount, + })} + + + )} {child} diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 5f63454b4e..80b1b0cd8f 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -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, ]); -}; +}; \ No newline at end of file diff --git a/packages/features/bookings/components/event-meta/Occurences.tsx b/packages/features/bookings/components/event-meta/Occurences.tsx index 816fa654c3..e57d39afdc 100644 --- a/packages/features/bookings/components/event-meta/Occurences.tsx +++ b/packages/features/bookings/components/event-meta/Occurences.tsx @@ -14,25 +14,42 @@ import { useBookerTime } from "../../Booker/components/hooks/useBookerTime"; export const EventOccurences = ({ event }: { event: Pick }) => { 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 { - 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); }} /> diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index 7d46f4c896..0a398dd405 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -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, diff --git a/packages/platform/atoms/booker/types.ts b/packages/platform/atoms/booker/types.ts index 8ef1e9758b..bb4d16f90a 100644 --- a/packages/platform/atoms/booker/types.ts +++ b/packages/platform/atoms/booker/types.ts @@ -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; -}; +}; \ No newline at end of file