diff --git a/apps/web/pages/api/book/instant-event.ts b/apps/web/pages/api/book/instant-event.ts index 82079bbd46..bcabfa472f 100644 --- a/apps/web/pages/api/book/instant-event.ts +++ b/apps/web/pages/api/book/instant-event.ts @@ -12,7 +12,7 @@ async function handler(req: NextApiRequest & { userId?: number }) { const userIp = getIP(req); await checkRateLimitAndThrowError({ - rateLimitingType: "core", + rateLimitingType: "instantMeeting", identifier: `instant.event-${piiHasher.hash(userIp)}`, }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 51aae893ad..1e7a4874e7 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2301,6 +2301,7 @@ "locked_by_team_admin": "Locked by team admin", "app_not_connected": "You have not connected a {{appName}} account.", "connect_now": "Connect now", + "connect_now_unavailable_tooltip": "Connect now is unavailable right now. Please book from above slots or try again later.", "managed_event_dialog_confirm_button_one": "Replace & notify {{count}} member", "managed_event_dialog_confirm_button_other": "Replace & notify {{count}} members", "managed_event_dialog_title_one": "The url /{{slug}} already exists for {{count}} member. Do you want to replace it?", diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index a63f28639a..bb3764934a 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -92,7 +92,6 @@ const BookerComponent = ({ ); const selectedDate = useBookerStoreContext((state) => state.selectedDate); - const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate); const { shouldShowFormInDialog, @@ -145,7 +144,14 @@ const BookerComponent = ({ const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm; - const { handleBookEvent, errors, loadingStates, expiryTime, instantVideoMeetingUrl } = bookings; + const { + handleBookEvent, + errors, + loadingStates, + expiryTime, + instantVideoMeetingUrl, + instantConnectCooldownMs, + } = bookings; const watchedCfToken = bookingForm.watch("cfToken"); @@ -230,9 +236,9 @@ const BookerComponent = ({ setSelectedTimeslot(slot || null); }, [slot, setSelectedTimeslot]); - const onSubmit = (timeSlot?: string) => { + const onSubmit = (timeSlot?: string) => renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail(); - }; + const EventBooker = useMemo(() => { return bookerState === "booking" ? ( @@ -543,6 +549,7 @@ const BookerComponent = ({ style={{ animationDelay: "1s" }}> { onConnectNowInstantMeeting(); }} diff --git a/packages/features/bookings/Booker/components/InstantBooking.tsx b/packages/features/bookings/Booker/components/InstantBooking.tsx index 2f4b6355c3..8e31b81b6e 100644 --- a/packages/features/bookings/Booker/components/InstantBooking.tsx +++ b/packages/features/bookings/Booker/components/InstantBooking.tsx @@ -3,16 +3,19 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { User } from "@calcom/prisma/client"; import { UserAvatarGroupWithOrg } from "@calcom/ui/components/avatar"; import { Button } from "@calcom/ui/components/button"; +import { Tooltip } from "@calcom/ui/components/tooltip"; interface IInstantBookingProps { onConnectNow: () => void; event: Pick & { subsetOfUsers: (Pick & { bookerUrl: string })[]; }; + cooldownMs?: number; } -export const InstantBooking = ({ onConnectNow, event }: IInstantBookingProps) => { +export const InstantBooking = ({ onConnectNow, event, cooldownMs = 0 }: IInstantBookingProps) => { const { t } = useLocale(); + const disabled = cooldownMs > 0; return (
@@ -33,16 +36,33 @@ export const InstantBooking = ({ onConnectNow, event }: IInstantBookingProps) =>
{t("dont_want_to_wait")}
-
- +
+ {disabled ? ( + + + + + + ) : ( + + )}
); diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts index a612d0f728..3707335d1c 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookings.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts @@ -114,6 +114,43 @@ export interface IUseBookingErrors { export type UseBookingsReturnType = ReturnType; const STORAGE_KEY = "instantBookingData"; +const COOLDOWN_STORAGE_KEY = "instantBookingCooldownByEvent"; +const COOLDOWN_WINDOW_MS = 10 * 60 * 1000; // 10 minutes + +type InstantBookingCooldownMap = Record; + +const readInstantCooldownMap = (): InstantBookingCooldownMap => { + try { + const raw = localStorage.getItem(COOLDOWN_STORAGE_KEY); + return raw ? (JSON.parse(raw) as InstantBookingCooldownMap) : {}; + } catch { + return {}; + } +}; + +const writeInstantCooldownMap = (map: InstantBookingCooldownMap) => { + try { + localStorage.setItem(COOLDOWN_STORAGE_KEY, JSON.stringify(map)); + } catch { + // don't do anything + } +}; + +const getInstantCooldownRemainingMs = (eventTypeId?: number | null): number => { + if (!eventTypeId) return 0; + const map = readInstantCooldownMap(); + const lastTs = map[String(eventTypeId)]; + if (!lastTs) return 0; + const remaining = lastTs + COOLDOWN_WINDOW_MS - Date.now(); + return remaining > 0 ? remaining : 0; +}; + +const setInstantCooldownNow = (eventTypeId?: number | null) => { + if (!eventTypeId) return; + const map = readInstantCooldownMap(); + map[String(eventTypeId)] = Date.now(); + writeInstantCooldownMap(map); +}; const storeInLocalStorage = ({ eventTypeId, @@ -133,7 +170,6 @@ export const useBookings = ({ hashedLink, bookingForm, metadata, - teamMemberEmail, isBookingDryRun, }: IUseBookings) => { const router = useRouter(); @@ -179,6 +215,8 @@ export const useBookings = ({ } }, [eventTypeId, isInstantMeeting]); + const instantConnectCooldownMs = getInstantCooldownRemainingMs(eventTypeId); + const _instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery( { bookingId: bookingId, @@ -245,7 +283,7 @@ export const useBookings = ({ const { uid, paymentUid } = booking; const fullName = getFullName(bookingForm.getValues("responses.name")); - const users = !!event.data?.subsetOfHosts?.length + const users = event.data?.subsetOfHosts?.length ? event.data?.subsetOfHosts.map((host) => host.user) : event.data?.subsetOfUsers; @@ -368,6 +406,7 @@ export const useBookings = ({ expiryTime: responseData.expires, bookingId: responseData.bookingId, }); + setInstantCooldownNow(eventTypeId); } updateQueryParam("bookingId", responseData.bookingId); @@ -472,7 +511,19 @@ export const useBookings = ({ bookingForm, hashedLink, metadata, - handleInstantBooking: createInstantBookingMutation.mutate, + handleInstantBooking: ( + variables: Parameters[0] + ) => { + const remaining = getInstantCooldownRemainingMs(eventTypeId); + if (remaining > 0) { + showToast( + t("please_try_again_later_or_book_another_slot"), + "error" + ); + return; + } + createInstantBookingMutation.mutate(variables); + }, handleRecBooking: createRecurringBookingMutation.mutate, handleBooking: createBookingMutation.mutate, isBookingDryRun, @@ -506,5 +557,6 @@ export const useBookings = ({ errors, loadingStates, instantVideoMeetingUrl, + instantConnectCooldownMs, }; }; diff --git a/packages/lib/rateLimit.ts b/packages/lib/rateLimit.ts index aa424ae681..5a0723774f 100644 --- a/packages/lib/rateLimit.ts +++ b/packages/lib/rateLimit.ts @@ -8,7 +8,15 @@ const log = logger.getSubLogger({ prefix: ["RateLimit"] }); export { type RatelimitResponse }; export type RateLimitHelper = { - rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api" | "ai" | "sms" | "smsMonth"; + rateLimitingType?: + | "core" + | "forcedSlowMode" + | "common" + | "api" + | "ai" + | "sms" + | "smsMonth" + | "instantMeeting"; identifier: string; opts?: LimitOptions; /** @@ -51,6 +59,14 @@ export function rateLimiter() { timeout, onError, }), + instantMeeting: new Ratelimit({ + rootKey: UNKEY_ROOT_KEY, + namespace: "instantMeeting", + limit: 1, + duration: "10m", + timeout, + onError, + }), common: new Ratelimit({ rootKey: UNKEY_ROOT_KEY, namespace: "common",