From 8615ccb1aa480809fa088ce2cf65eb52357195ed Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Wed, 29 Jan 2025 17:21:58 +0530 Subject: [PATCH 01/34] feat: routing forms integration for booker atom (#18726) * update booker atom for routing form * remove logs * hide routing form properties from docs * fixup * add comments to explain why certain fields are not defined for routing forms in v2 * chore: routing for handler return team and org id * bump libraries platform * fixup! bump libraries platform * chore: get routing form params to embed * logs to remove * fix import path * chore: handle routing form params in booker embed * wip need router api v2 endpoint * resolve merge conflicts * fixup * fixup! Merge branch 'main' into remove-redirect-on-success-for-platform * fixup! fixup! Merge branch 'main' into remove-redirect-on-success-for-platform * router atom * fix libraries version * cleanup * remove console logs * bump libraries --------- Co-authored-by: Morgan Vernay Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- apps/api/v2/package.json | 2 +- .../2024-04-15/inputs/create-booking.input.ts | 45 +++- .../router/controllers/router.controller.ts | 20 +- packages/features/bookings/Booker/Booker.tsx | 6 +- packages/features/bookings/Booker/types.ts | 2 + .../booking-to-mutation-input-mapper.tsx | 5 +- packages/lib/server/getRoutedUrl.ts | 6 +- .../atoms/booker-embed/BookerEmbed.tsx | 83 ++++++- .../useGetRoutingFormUrlProps.tsx | 62 +++++ .../atoms/booker/BookerPlatformWrapper.tsx | 42 +++- packages/platform/atoms/globals.css | 228 +++++++----------- .../hooks/bookings/useHandleBookEvent.ts | 4 + .../platform/atoms/hooks/useAvailableSlots.ts | 3 + packages/platform/atoms/index.ts | 2 + packages/platform/atoms/router/Router.tsx | 127 ++++++++++ packages/platform/atoms/router/index.ts | 1 + .../platform/examples/base/src/pages/_app.tsx | 17 +- .../examples/base/src/pages/router.tsx | 15 ++ packages/platform/types/embed.ts | 16 ++ packages/platform/types/index.ts | 1 + packages/platform/types/slots.ts | 30 ++- 21 files changed, 546 insertions(+), 171 deletions(-) create mode 100644 packages/platform/atoms/booker-embed/useGetRoutingFormUrlProps.tsx create mode 100644 packages/platform/atoms/router/Router.tsx create mode 100644 packages/platform/atoms/router/index.ts create mode 100644 packages/platform/examples/base/src/pages/router.tsx create mode 100644 packages/platform/types/embed.ts diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index bbea556700c888..99448e1f5eb365 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -29,7 +29,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.92", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.93", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts index 2ae61c86536c7c..675b94cf698c6f 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional, ApiHideProperty } from "@nestjs/swagger"; import { Transform, Type } from "class-transformer"; import { IsBoolean, @@ -126,4 +126,47 @@ export class CreateBookingInput_2024_04_15 { @IsOptional() @ApiPropertyOptional() locationUrl?: string; + + // note(rajiv): after going through getUrlSearchParamsToForward.ts we found out + // that the below properties were not being included inside of handleNewBooking :- cc @morgan + // cal.salesforce.rrSkipToAccountLookupField, cal.rerouting & cal.isTestPreviewLink + // hence no input values have been setup for them in CreateBookingInput_2024_04_15 + @IsArray() + @Type(() => Number) + @IsOptional() + @ApiHideProperty() + routedTeamMemberIds?: number[]; + + @IsNumber() + @IsOptional() + @ApiHideProperty() + routingFormResponseId?: number; + + @IsBoolean() + @IsOptional() + @ApiHideProperty() + skipContactOwner?: boolean; + + @IsBoolean() + @IsOptional() + @ApiHideProperty() + _shouldServeCache?: boolean; + + @IsBoolean() + @IsOptional() + @ApiHideProperty() + _isDryRun?: boolean; + + // reroutingFormResponses is similar to rescheduling which can only be done by the organiser + // won't really be necessary here in our usecase though :- cc @Hariom + @IsObject() + @IsOptional() + @ApiHideProperty() + reroutingFormResponses?: Record< + string, + { + value: (string | number | string[]) & (string | number | string[] | undefined); + label?: string | undefined; + } + >; } diff --git a/apps/api/v2/src/modules/router/controllers/router.controller.ts b/apps/api/v2/src/modules/router/controllers/router.controller.ts index 78c2c728c7a3af..7ead58090ffbb5 100644 --- a/apps/api/v2/src/modules/router/controllers/router.controller.ts +++ b/apps/api/v2/src/modules/router/controllers/router.controller.ts @@ -1,7 +1,7 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; -import { Controller, Get, Req, NotFoundException, Res, Query, Param } from "@nestjs/common"; +import { Controller, Req, NotFoundException, Param, Post, Body } from "@nestjs/common"; import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; -import { Request, Response } from "express"; +import { Request } from "express"; import { getRoutedUrl } from "@calcom/platform-libraries"; import { ApiResponse } from "@calcom/platform-types"; @@ -13,27 +13,27 @@ import { ApiResponse } from "@calcom/platform-types"; @DocsTags("Router controller") @DocsExcludeController(true) export class RouterController { - @Get("/forms/:formId") + @Post("/forms/:formId/submit") async getRoutingFormResponse( @Req() request: Request, - @Res() res: Response, @Param("formId") formId: string, - @Query() query: Record - ): Promise> { - const routedUrlData = await getRoutedUrl({ req: request, query: { ...query, form: formId } }); + @Body() body?: Record + ): Promise & { redirect: boolean })> { + const params = Object.fromEntries(new URLSearchParams(body ?? {})); + const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } }); if (routedUrlData?.notFound) { throw new NotFoundException("Route not found. Please check the provided form parameter."); } if (routedUrlData?.redirect?.destination) { - return res.redirect(307, routedUrlData.redirect.destination); + return { status: "success", data: routedUrlData?.redirect?.destination, redirect: true }; } if (routedUrlData?.props) { - return { status: "success", data: routedUrlData.props }; + return { status: "success", data: { message: routedUrlData?.props?.message ?? "" }, redirect: false }; } - return { status: "success", data: "route nor custom message found" }; + return { status: "success", data: { message: "No Route nor custom message found." }, redirect: false }; } } diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 726fcd840d0449..827d498ef2676c 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -72,6 +72,7 @@ const BookerComponent = ({ areInstantMeetingParametersSet = false, userLocale, hasValidLicense, + isBookingDryRun: isBookingDryRunProp, renderCaptcha, }: BookerProps & WrappedBookerProps) => { const searchParams = useCompatSearchParams(); @@ -273,14 +274,15 @@ const BookerComponent = ({ <> {event.data && !isPlatform ? : <>} - {isBookingDryRun(searchParams) && } + {(isBookingDryRunProp || isBookingDryRun(searchParams)) && }
{ - const searchParams = new URLSearchParams(window.location.search); + const searchParams = new URLSearchParams(routingFormSearchParams ?? window.location.search); const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams); const routingFormResponseIdParam = searchParams.get("cal.routingFormResponseId"); const routingFormResponseId = routingFormResponseIdParam ? Number(routingFormResponseIdParam) : undefined; diff --git a/packages/lib/server/getRoutedUrl.ts b/packages/lib/server/getRoutedUrl.ts index 6b1794d10c7eab..d5703b319f9e97 100644 --- a/packages/lib/server/getRoutedUrl.ts +++ b/packages/lib/server/getRoutedUrl.ts @@ -171,7 +171,9 @@ export const getRoutedUrl = async (context: Pick { - return ( - - - - ); + // Use Routing Form Url To Display Correct Booker + const routingFormUrlProps = useGetRoutingFormUrlProps(props); + if (props?.routingFormUrl && routingFormUrlProps) { + const { + organizationId, + teamId: routingTeamId, + eventTypeSlug, + username, + ...routingFormSearchParams + } = routingFormUrlProps; + return ( + + { + window.location.href = `https://app.cal.com/booking/dry-run-successful`; + }} + /> + + ); + } + + // If Not For From Routing Form, Use Props + if (props?.routingFormUrl === undefined) { + return ( + + + + ); + } + + return <>; }; diff --git a/packages/platform/atoms/booker-embed/useGetRoutingFormUrlProps.tsx b/packages/platform/atoms/booker-embed/useGetRoutingFormUrlProps.tsx new file mode 100644 index 00000000000000..76d1fbba0703b5 --- /dev/null +++ b/packages/platform/atoms/booker-embed/useGetRoutingFormUrlProps.tsx @@ -0,0 +1,62 @@ +import { useMemo } from "react"; + +import type { RoutingFormSearchParamsForEmbed } from "@calcom/platform-types"; + +export const useGetRoutingFormUrlProps = ({ routingFormUrl }: { routingFormUrl?: string }) => { + const routingFormUrlProps = useMemo(() => { + if (routingFormUrl) { + const routingUrl = new URL(routingFormUrl); + const pathNameParams = routingUrl.pathname.split("/"); + + if (pathNameParams.length < 2) { + throw new Error("Invalid routing form url."); + } + + const eventTypeSlug = pathNameParams[pathNameParams.length - 1]; + const isTeamUrl = pathNameParams[1] === "team"; + const username = isTeamUrl ? undefined : pathNameParams[1]; + const routingSearchParams = routingUrl.searchParams; + if (!eventTypeSlug) { + throw new Error("Event type slug is not defined within the routing form url"); + } + if (!isTeamUrl && !username) { + throw new Error("username not defined within the routing form url"); + } + return { + organizationId: routingSearchParams.get("cal.orgId") + ? Number(routingSearchParams.get("cal.orgId")) + : undefined, + teamId: routingSearchParams.get("cal.teamId") + ? Number(routingSearchParams.get("cal.teamId")) + : undefined, + username, + eventTypeSlug, + ...(routingSearchParams.get("cal.routedTeamMemberIds") && { + ["cal.routedTeamMemberIds"]: routingSearchParams.get("cal.routedTeamMemberIds") ?? undefined, + }), + ...(routingSearchParams.get("cal.reroutingFormResponses") && { + ["cal.reroutingFormResponses"]: routingSearchParams.get("cal.reroutingFormResponses") ?? undefined, + }), + ...(routingSearchParams.get("cal.skipContactOwner") && { + ["cal.skipContactOwner"]: routingSearchParams.get("cal.skipContactOwner") ?? undefined, + }), + ...(routingSearchParams.get("cal.isBookingDryRun") && { + ["cal.isBookingDryRun"]: routingSearchParams.get("cal.isBookingDryRun") ?? undefined, + }), + ...(routingSearchParams.get("cal.cache") && { + ["cal.cache"]: routingSearchParams.get("cal.cache") ?? undefined, + }), + ...(routingSearchParams.get("cal.routingFormResponseId") && { + ["cal.routingFormResponseId"]: routingSearchParams.get("cal.routingFormResponseId") ?? undefined, + }), + ...(routingSearchParams.get("cal.salesforce.rrSkipToAccountLookupField") && { + ["cal.salesforce.rrSkipToAccountLookupField"]: + routingSearchParams.get("cal.salesforce.rrSkipToAccountLookupField") ?? undefined, + }), + } satisfies RoutingFormSearchParamsForEmbed; + } + return; + }, [routingFormUrl]); + + return routingFormUrlProps; +}; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 977b85b2157b0f..a699475f3751fd 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -11,6 +11,7 @@ import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/u import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; import { useTimePreferences } from "@calcom/features/bookings/lib"; import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule"; +import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams"; import { getUsernameList } from "@calcom/lib/defaultEvents"; import { localStorage } from "@calcom/lib/webstorage"; import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries"; @@ -20,6 +21,7 @@ import type { ApiSuccessResponse, ApiSuccessResponseWithoutData, } from "@calcom/platform-types"; +import type { RoutingFormSearchParams } from "@calcom/platform-types"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { @@ -78,6 +80,7 @@ export type BookerPlatformWrapperAtomProps = Omit< view?: VIEW_TYPE; metadata?: Record; bannerUrl?: string; + onDryRunSuccess?: () => void; }; type VIEW_TYPE = keyof typeof BookerLayouts; @@ -85,18 +88,20 @@ type VIEW_TYPE = keyof typeof BookerLayouts; export type BookerPlatformWrapperAtomPropsForIndividual = BookerPlatformWrapperAtomProps & { username: string | string[]; isTeamEvent?: false; + routingFormSearchParams?: RoutingFormSearchParams; }; export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomProps & { username?: string | string[]; isTeamEvent: true; teamId: number; + routingFormSearchParams?: RoutingFormSearchParams; }; export const BookerPlatformWrapper = ( props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam ) => { - const { view = "MONTH_VIEW", bannerUrl } = props; + const { view = "MONTH_VIEW", bannerUrl, routingFormSearchParams } = props; const layout = BookerLayouts[view]; const { clientId } = useAtomsContext(); @@ -259,6 +264,32 @@ export const BookerPlatformWrapper = ( selectedDate, }); + const [routingParams, setRoutingParams] = useState<{ + routedTeamMemberIds?: number[]; + shouldServeCache?: boolean; + skipContactOwner?: boolean; + isBookingDryRun?: boolean; + }>({}); + + useEffect(() => { + const searchParams = routingFormSearchParams + ? new URLSearchParams(routingFormSearchParams) + : new URLSearchParams(window.location.search); + + const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams); + const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true"; + + const _cacheParam = searchParams?.get("cal.cache"); + const shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined; + const isBookingDryRun = searchParams?.get("cal.isBookingDryRun")?.toLowerCase() === "true"; + setRoutingParams({ + ...(skipContactOwner ? { skipContactOwner } : {}), + ...(routedTeamMemberIds ? { routedTeamMemberIds } : {}), + ...(shouldServeCache ? { shouldServeCache } : {}), + ...(isBookingDryRun ? { isBookingDryRun } : {}), + }); + }, [routingFormSearchParams]); + const schedule = useAvailableSlots({ usernameList: getUsernameList(username), eventTypeId: event?.data?.id ?? 0, @@ -281,6 +312,7 @@ export const BookerPlatformWrapper = ( Boolean(event?.data?.id), orgSlug: props.entity?.orgSlug ?? undefined, eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "", + ...routingParams, }); const bookerForm = useBookingForm({ @@ -302,6 +334,9 @@ export const BookerPlatformWrapper = ( isError: isCreateBookingError, } = useCreateBooking({ onSuccess: (data) => { + if (data?.data?.isDryRun) { + props?.onDryRunSuccess?.(); + } schedule.refetch(); props.onCreateBookingSuccess?.(data); @@ -319,6 +354,9 @@ export const BookerPlatformWrapper = ( isError: isCreateRecBookingError, } = useCreateRecurringBooking({ onSuccess: (data) => { + if (data?.data?.[0]?.isDryRun) { + props?.onDryRunSuccess?.(); + } schedule.refetch(); props.onCreateRecurringBookingSuccess?.(data); @@ -383,6 +421,7 @@ export const BookerPlatformWrapper = ( handleInstantBooking: createInstantBooking, handleRecBooking: createRecBooking, locationUrl: props.locationUrl, + routingFormSearchParams, }); const onOverlaySwitchStateChange = useCallback( @@ -514,6 +553,7 @@ export const BookerPlatformWrapper = ( verifyCode={undefined} isPlatform hasValidLicense={true} + isBookingDryRun={routingParams?.isBookingDryRun} /> ); diff --git a/packages/platform/atoms/globals.css b/packages/platform/atoms/globals.css index 2f9bcdaf45c888..7648d22fcc4919 100644 --- a/packages/platform/atoms/globals.css +++ b/packages/platform/atoms/globals.css @@ -6,6 +6,7 @@ @import "/packages/ui/styles/shared-globals.css"; @import "/apps/web/styles/globals.css"; +@custom-variant dark (&:where(.dark, .dark *)); @layer base { :root { @@ -39,48 +40,6 @@ --ring: 215 20.2% 65.1%; --radius: 0.5rem; - /* background */ - - --cal-bg-emphasis: #e5e7eb; - --cal-bg: white; - --cal-bg-subtle: #f3f4f6; - --cal-bg-muted: #f9fafb; - --cal-bg-inverted: #111827; - - /* background -> components*/ - --cal-bg-info: #dee9fc; - --cal-bg-success: #e2fbe8; - --cal-bg-attention: #fceed8; - --cal-bg-error: #f9e3e2; - --cal-bg-dark-error: #752522; - - /* Borders */ - --cal-border-emphasis: #9ca3af; - --cal-border: #d1d5db; - --cal-border-subtle: #e5e7eb; - --cal-border-booker: #e5e7eb; - --cal-border-muted: #f3f4f6; - --cal-border-error: #aa2e26; - - /* Content/Text */ - --cal-text-emphasis: #111827; - --cal-text: #374151; - --cal-text-subtle: #6b7280; - --cal-text-muted: #9ca3af; - --cal-text-inverted: white; - - /* Content/Text -> components */ - --cal-text-info: #253985; - --cal-text-success: #285231; - --cal-text-attention: #73321b; - --cal-text-error: #752522; - - /* Brand shinanigans - -> These will be computed for the users theme at runtime. - */ - --cal-brand: #111827; - --cal-brand-emphasis: #101010; - --cal-brand-text: white; } .dark { @@ -114,46 +73,6 @@ --ring: 216 34% 17%; --radius: 0.5rem; - --cal-bg-emphasis: #2b2b2b; - --cal-bg: #101010; - --cal-bg-subtle: #2b2b2b; - --cal-bg-muted: #1c1c1c; - --cal-bg-inverted: #f3f4f6; - - /* background -> components*/ - --cal-bg-info: #263fa9; - --cal-bg-success: #306339; - --cal-bg-attention: #8e3b1f; - --cal-bg-error: #8c2822; - --cal-bg-dark-error: #752522; - - /* Borders */ - --cal-border-emphasis: #575757; - --cal-border: #444444; - --cal-border-subtle: #2b2b2b; - --cal-border-booker: #2b2b2b; - --cal-border-muted: #1c1c1c; - --cal-border-error: #aa2e26; - - /* Content/Text */ - --cal-text-emphasis: #f3f4f6; - --cal-text: #d6d6d6; - --cal-text-subtle: #a5a5a5; - --cal-text-muted: #575757; - --cal-text-inverted: #101010; - - /* Content/Text -> components */ - --cal-text-info: #dee9fc; - --cal-text-success: #e2fbe8; - --cal-text-attention: #fceed8; - --cal-text-error: #f9e3e2; - - /* Brand shenanigans - -> These will be computed for the users theme at runtime. - */ - --cal-brand: white; - --cal-brand-emphasis: #e1e1e1; - --cal-brand-text: black; } } @@ -13321,68 +13240,101 @@ select { grid-area: timeslots } + + :root { - --cal-bg-emphasis: #e5e7eb; - --cal-bg: #fff; - --cal-bg-subtle: #f3f4f6; - --cal-bg-muted: #f9fafb; - --cal-bg-inverted: #0f0f0f; - --cal-bg-info: #f6f9fe; - --cal-bg-success: #e4fbe9; - --cal-bg-attention: #fcefd9; - --cal-bg-error: hsla(3,66,93,1); - --cal-bg-dark-error: #772522; - --cal-border-emphasis: #9ca3b0; - --cal-border: #d1d5db; - --cal-border-subtle: #e5e7eb; - --cal-border-booker: #e5e7eb; - --cal-border-muted: #f3f4f6; - --cal-border-error: #aa2f27; - --cal-text-emphasis: #384252; - --cal-text: #384252; - --cal-text-subtle: #6b7280; - --cal-text-muted: #9ca3b0; - --cal-text-inverted: #fff; - --cal-text-info: #253883; - --cal-text-success: #285231; - --cal-text-attention: #74331b; - --cal-text-error: #772522; - --cal-brand: #111827; - --cal-brand-emphasis: #0f0f0f; - --cal-brand-text: #fff +/* background */ + +--cal-bg-emphasis: hsla(220,13%,91%,1); +--cal-bg: hsla(0,0%,100%,1); +--cal-bg-subtle: hsla(220, 14%, 96%,1); +--cal-bg-muted: hsla(210,20%,98%,1); +--cal-bg-inverted: hsla(0,0%,6%,1); + +/* background -> components*/ +--cal-bg-info: hsla(218,83%,98%,1); +--cal-bg-success: hsla(134,76%,94%,1); +--cal-bg-attention: hsla(37, 86%, 92%, 1); +--cal-bg-error: hsla(3,66%,93%,1); +--cal-bg-dark-error: hsla(2, 55%, 30%, 1); + +/* Borders */ +--cal-border-emphasis: hsla(218, 11%, 65%, 1); +--cal-border: hsla(216, 12%, 84%, 1); +--cal-border-subtle: hsla(220, 13%, 91%, 1); +--cal-border-booker: #e5e7eb; +--cal-border-muted: hsla(220, 14%, 96%, 1); +--cal-border-error: hsla(4, 63%, 41%, 1); +--cal-border-focus: hsla(0, 0%, 10%, 1); + +/* Content/Text */ +--cal-text-emphasis: hsla(217, 19%, 27%, 1); +--cal-text: hsla(217, 19%, 27%, 1); +--cal-text-subtle: hsla(220, 9%, 46%, 1); +--cal-text-muted: hsla(218, 11%, 65%, 1); +--cal-text-inverted: hsla(0, 0%, 100%, 1); + +/* Content/Text -> components */ +--cal-text-info: hsla(228, 56%, 33%, 1); +--cal-text-success: hsla(133, 34%, 24%, 1); +--cal-text-attention: hsla(16, 62%, 28%, 1); +--cal-text-error: hsla(2, 55%, 30%, 1); + +/* Brand shinanigans + -> These will be computed for the users theme at runtime. + */ +--cal-brand: hsla(221, 39%, 11%, 1); +--cal-brand-emphasis: hsla(0, 0%, 6%, 1); +--cal-brand-text: hsla(0, 0%, 100%, 1); } .dark { - --cal-bg-emphasis: #404040; - --cal-bg: #1a1a1a; - --cal-bg-subtle: #2e2e2e; - --cal-bg-muted: #1f1f1f; - --cal-bg-inverted: #f3f4f6; - --cal-bg-info: #253883; - --cal-bg-success: #285231; - --cal-bg-attention: #74331b; - --cal-bg-error: #772522; - --cal-bg-dark-error: #772522; - --cal-border-emphasis: #757575; - --cal-border: #575757; - --cal-border-subtle: #383838; - --cal-border-booker: #383838; - --cal-border-muted: #2e2e2e; - --cal-border-error: #aa2f27; - --cal-text-emphasis: #fcfcfd; - --cal-text: #d6d6d6; - --cal-text-subtle: #a6a6a6; - --cal-text-muted: #575757; - --cal-text-inverted: #1a1a1a; - --cal-text-info: #dee9fc; - --cal-text-success: #e4fbe9; - --cal-text-attention: #fcefd9; - --cal-text-error: #f9e3e1; - --cal-brand: #fff; - --cal-brand-emphasis: #9ca3b0; - --cal-brand-text: #000 + /* background */ + + --cal-bg-emphasis: hsla(0, 0%, 25%, 1); + --cal-bg: hsla(0, 0%, 10%, 1); + --cal-bg-subtle: hsla(0, 0%, 18%, 1); + --cal-bg-muted: hsla(0, 0%, 12%, 1); + --cal-bg-inverted: hsla(220, 14%, 96%, 1); + + /* background -> components*/ + --cal-bg-info: hsla(228, 56%, 33%, 1); + --cal-bg-success: hsla(133, 34%, 24%, 1); + --cal-bg-attention: hsla(16, 62%, 28%, 1); + --cal-bg-error: hsla(2, 55%, 30%, 1); + --cal-bg-dark-error: hsla(2, 55%, 30%, 1); + + /* Borders */ + --cal-border-emphasis: hsla(0, 0%, 46%, 1); + --cal-border: hsla(0, 0%, 34%, 1); + --cal-border-subtle: hsla(0, 0%, 22%, 1); + --cal-border-booker: hsla(0, 0%, 22%, 1); + --cal-border-muted: hsla(0, 0%, 18%, 1); + --cal-border-error: hsla(4, 63%, 41%, 1); + --cal-border-focus: hsla(0, 0%, 100%, 1); + + /* Content/Text */ + --cal-text-emphasis: hsla(240, 20%, 99%, 1); + --cal-text: hsla(0, 0%, 84%, 1); + --cal-text-subtle: hsla(0, 0%, 65%, 1); + --cal-text-muted: hsla(0, 0%, 34%, 1); + --cal-text-inverted: hsla(0, 0%, 10%, 1); + + /* Content/Text -> components */ + --cal-text-info: hsla(218, 83%, 93%, 1); + --cal-text-success: hsla(134, 76%, 94%, 1); + --cal-text-attention: hsla(37, 86%, 92%, 1); + --cal-text-error: hsla(3, 66%, 93%, 1); + + /* Brand shenanigans + -> These will be computed for the users theme at runtime. + */ + --cal-brand: hsla(0, 0%, 100%, 1); + --cal-brand-emphasis: hsla(218, 11%, 65%, 1); + --cal-brand-text: hsla(0, 0%, 0%,1); } + ::-moz-selection { color: var(--cal-brand-text); background: var(--cal-brand) diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index 0dbcbbcaf5dcfd..3bef4e4dc78c51 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -5,6 +5,7 @@ import { setLastBookingResponse } from "@calcom/features/bookings/Booker/utils/l import { mapBookingToMutationInput, mapRecurringBookingToMutationInput } from "@calcom/features/bookings/lib"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RoutingFormSearchParams } from "@calcom/platform-types"; import type { BookingCreateBody } from "@calcom/prisma/zod-utils"; import type { UseCreateBookingInput } from "./useCreateBooking"; @@ -23,6 +24,7 @@ type UseHandleBookingProps = { handleInstantBooking: (input: BookingCreateBody) => void; handleRecBooking: (input: BookingCreateBody[]) => void; locationUrl?: string; + routingFormSearchParams?: RoutingFormSearchParams; }; export const useHandleBookEvent = ({ @@ -34,6 +36,7 @@ export const useHandleBookEvent = ({ handleInstantBooking, handleRecBooking, locationUrl, + routingFormSearchParams, }: UseHandleBookingProps) => { const setFormValues = useBookerStore((state) => state.setFormValues); const timeslot = useBookerStore((state) => state.selectedTimeslot); @@ -93,6 +96,7 @@ export const useHandleBookEvent = ({ crmOwnerRecordType, crmAppSlug, orgSlug: orgSlug ? orgSlug : undefined, + routingFormSearchParams, }; if (isInstantMeeting) { diff --git a/packages/platform/atoms/hooks/useAvailableSlots.ts b/packages/platform/atoms/hooks/useAvailableSlots.ts index dc5e1ab9d60d4a..f5bf70425f7286 100644 --- a/packages/platform/atoms/hooks/useAvailableSlots.ts +++ b/packages/platform/atoms/hooks/useAvailableSlots.ts @@ -22,6 +22,9 @@ export const useAvailableSlots = ({ rest.isTeamEvent ?? false, rest.teamId ?? false, rest.usernameList, + rest.routedTeamMemberIds, + rest.skipContactOwner, + rest.shouldServeCache, ], queryFn: () => { return http diff --git a/packages/platform/atoms/index.ts b/packages/platform/atoms/index.ts index 806dbcdcf76a7b..8e611b67dc6220 100644 --- a/packages/platform/atoms/index.ts +++ b/packages/platform/atoms/index.ts @@ -16,6 +16,8 @@ export { useMe } from "./hooks/useMe"; export { OutlookConnect } from "./connect/outlook/OutlookConnect"; export * as Connect from "./connect"; export { BookerEmbed } from "./booker-embed"; +export { Router } from "./router"; + export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalendarCredentials"; export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar"; export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar"; diff --git a/packages/platform/atoms/router/Router.tsx b/packages/platform/atoms/router/Router.tsx new file mode 100644 index 00000000000000..11b855a4892f62 --- /dev/null +++ b/packages/platform/atoms/router/Router.tsx @@ -0,0 +1,127 @@ +import type { ReactElement } from "react"; +import React, { useState } from "react"; + +import { BookerEmbed } from "../booker-embed"; +import type { BookerPlatformWrapperAtomPropsForTeam } from "../booker/BookerPlatformWrapper"; + +/** + * Renders the Router component with predefined props. + * Depending on the routing form either renders a custom message, redirects or display Booker embed atom. + * formResponsesURLParams contains the answers to the questions fields defined in the form. + * ```tsx + * + * ``` + */ + +export const Router = React.memo( + ({ + formId, + formResponsesURLParams, + onExternalRedirect, + onDisplayBookerEmbed, + renderMessage, + bookerBannerUrl, + bookerCustomClassNames, + }: { + formId: string; + formResponsesURLParams?: URLSearchParams; + onExternalRedirect?: () => void; + onDisplayBookerEmbed?: () => void; + renderMessage?: (message?: string) => ReactElement | ReactElement[]; + bookerBannerUrl?: BookerPlatformWrapperAtomPropsForTeam["bannerUrl"]; + bookerCustomClassNames?: BookerPlatformWrapperAtomPropsForTeam["customClassNames"]; + }) => { + const [isLoading, setIsLoading] = useState(); + const [routerUrl, setRouterUrl] = useState(); + const [routingData, setRoutingData] = useState<{ message: string } | undefined>(); + const [isError, setIsError] = useState(); + + React.useEffect(() => { + if (!isLoading) { + setIsLoading(true); + setIsError(false); + setRoutingData(undefined); + setRouterUrl(""); + + const baseUrl = import.meta.env.VITE_BOOKER_EMBED_API_URL; + fetch(`${baseUrl}/router/forms/${formId}/submit`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: formResponsesURLParams + ? JSON.stringify(Object.fromEntries(formResponsesURLParams)) + : undefined, + }) + .then(async (response) => { + const body: + | { status: string; data: string; redirect: true } + | { status: string; data: { message: string }; redirect: false } = await response.json(); + if (body.redirect) { + setRouterUrl(body.data); + } else { + setRoutingData({ message: body.data?.message ?? "" }); + } + }) + .catch((err) => { + console.error(err); + setIsError(true); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, []); + + const isRedirect = !!routerUrl; + + if (isLoading || isError) { + return <>; + } + + if (!isLoading && isRedirect && routerUrl) { + const redirectParams = new URLSearchParams(routerUrl); + if (redirectParams.get("cal.action") === "eventTypeRedirectUrl") { + // display booker with redirect URL + onDisplayBookerEmbed?.(); + return ( + + ); + } else if (redirectParams.get("cal.action") === "externalRedirectUrl") { + onExternalRedirect?.(); + window.location.href = routerUrl; + return <>; + } + } + + if (!isRedirect && routingData?.message) { + if (renderMessage) { + return <>{renderMessage(routingData?.message)}; + } + return ( +
+
+
+
{routingData?.message}
+
+
+
+ ); + } + + return <>; + } +); + +Router.displayName = "RouterAtom"; diff --git a/packages/platform/atoms/router/index.ts b/packages/platform/atoms/router/index.ts new file mode 100644 index 00000000000000..19f100fac3cb3f --- /dev/null +++ b/packages/platform/atoms/router/index.ts @@ -0,0 +1 @@ +export { Router } from "./Router"; diff --git a/packages/platform/examples/base/src/pages/_app.tsx b/packages/platform/examples/base/src/pages/_app.tsx index fa67e540672b03..c826006aca9faa 100644 --- a/packages/platform/examples/base/src/pages/_app.tsx +++ b/packages/platform/examples/base/src/pages/_app.tsx @@ -7,7 +7,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import Select from "react-select"; -import { CalProvider, BookerEmbed } from "@calcom/atoms"; +import { CalProvider, BookerEmbed, Router } from "@calcom/atoms"; import "@calcom/atoms/globals.min.css"; const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] }); @@ -121,6 +121,21 @@ export default function App({ Component, pageProps }: AppProps) { />
)} + {pathname === "/router" && ( +
+ { + console.log("render booker embed"); + }} + bookerBannerUrl="https://i0.wp.com/mahala.co.uk/wp-content/uploads/2014/12/img_banner-thin_mountains.jpg?fit=800%2C258&ssl=1" + bookerCustomClassNames={{ + bookerWrapper: "dark", + }} + /> +
+ )}
); } diff --git a/packages/platform/examples/base/src/pages/router.tsx b/packages/platform/examples/base/src/pages/router.tsx new file mode 100644 index 00000000000000..ab52fc7d2de1e1 --- /dev/null +++ b/packages/platform/examples/base/src/pages/router.tsx @@ -0,0 +1,15 @@ +import { Navbar } from "@/components/Navbar"; +import { Inter } from "next/font/google"; + +const inter = Inter({ subsets: ["latin"] }); + +export default function Router(props: { calUsername: string; calEmail: string }) { + return ( +
+ +
+

This is the router atom

+
+
+ ); +} diff --git a/packages/platform/types/embed.ts b/packages/platform/types/embed.ts new file mode 100644 index 00000000000000..3357d00020b76b --- /dev/null +++ b/packages/platform/types/embed.ts @@ -0,0 +1,16 @@ +export type RoutingFormSearchParamsForEmbed = { + organizationId?: number; + teamId?: number; + eventTypeSlug: string; + username?: string; +} & RoutingFormSearchParams; + +export type RoutingFormSearchParams = { + ["cal.routedTeamMemberIds"]?: string; + ["cal.reroutingFormResponses"]?: string; + ["cal.skipContactOwner"]?: string; + ["cal.isBookingDryRun"]?: string; + ["cal.cache"]?: string; + ["cal.routingFormResponseId"]?: string; + ["cal.salesforce.rrSkipToAccountLookupField"]?: string; +}; diff --git a/packages/platform/types/index.ts b/packages/platform/types/index.ts index 4ba50e7540dd48..9b23abb356a81f 100644 --- a/packages/platform/types/index.ts +++ b/packages/platform/types/index.ts @@ -9,3 +9,4 @@ export * from "./schedules"; export * from "./event-types"; export * from "./organizations"; export * from "./teams"; +export * from "./embed"; diff --git a/packages/platform/types/slots.ts b/packages/platform/types/slots.ts index 3a819f5cbfeb8c..693c5a2942e646 100644 --- a/packages/platform/types/slots.ts +++ b/packages/platform/types/slots.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional, ApiHideProperty } from "@nestjs/swagger"; import { Transform } from "class-transformer"; import { IsArray, @@ -96,6 +96,34 @@ export class GetAvailableSlotsInput { enum: SlotFormat, }) slotFormat?: SlotFormat; + + // note(rajiv): after going through getUrlSearchParamsToForward.ts we found out + // that the below properties were not being included inside getSlots :- cc @morgan + // cal.salesforce.rrSkipToAccountLookupField, cal.rerouting, cal.routingFormResponseId, cal.reroutingFormResponses & cal.isTestPreviewLink + // hence no input values have been setup for them in GetAvailableSlotsInput + @Transform(({ value }) => value && value.toLowerCase() === "true") + @IsBoolean() + @IsOptional() + @ApiHideProperty() + skipContactOwner?: boolean; + + @Transform(({ value }) => value && value.toLowerCase() === "true") + @IsBoolean() + @IsOptional() + @ApiHideProperty() + shouldServeCache?: boolean; + + @IsOptional() + @Transform(({ value }) => { + if (Array.isArray(value)) { + return value.map((s: string) => parseInt(s)); + } + return value; + }) + @IsArray() + @IsNumber({}, { each: true }) + @ApiHideProperty() + routedTeamMemberIds?: number[]; } export class RemoveSelectedSlotInput { From 92678dc042caf9fb1a3bb67bdc839bad428d34ed Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:00:51 +0400 Subject: [PATCH 02/34] fix: eventtype null cant be booked error (#18975) * fix err msg and add more description * improvements * fix cause * add warn logs for when failing to specific limit checks * typefix * safestringify log * improve error * test fix and log min booking notice * adds out of bounds error code * test fix * fix --- apps/web/public/static/locales/en/common.json | 1 + .../test/booking-limits.test.ts | 6 +- .../validateBookingTimeIsNotOutOfBounds.ts | 16 ++--- packages/lib/errorCodes.ts | 1 + packages/lib/isOutOfBounds.tsx | 71 ++++++++++++++----- 5 files changed, 64 insertions(+), 31 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 83190cb49c0613..1fcdde96b1c53d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -83,6 +83,7 @@ "payment_not_created_error": "Payment could not be created", "couldnt_charge_card_error": "Could not charge card for Payment", "no_available_users_found_error": "No available users found. Could you try another time slot?", + "booking_time_out_of_bounds_error": "The event type cannot be booked at this time. Could you try another time slot?", "request_body_end_time_internal_error": "Internal Error. Request body does not contain end time", "create_calendar_event_error": "Unable to create Calendar event in Organizer's calendar", "update_calendar_event_error": "Unable to update Calendar event.", diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts index 81044e461fcc06..37342f057cfa62 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -772,7 +772,9 @@ describe("handleNewBooking", () => { mockCalendarToHaveNoBusySlots("googlecalendar", {}); await createBookingScenario(scenarioData); - await expect(() => handleNewBooking(req)).rejects.toThrowError("book a meeting in the past"); + await expect(() => handleNewBooking(req)).rejects.toThrowError( + "Attempting to book a meeting in the past." + ); }, timeout ); @@ -865,7 +867,7 @@ describe("handleNewBooking", () => { }, }); - expect(() => handleNewBooking(req)).rejects.toThrowError("cannot be booked at this time"); + expect(() => handleNewBooking(req)).rejects.toThrowError("booking_time_out_of_bounds_error"); }, timeout ); diff --git a/packages/features/bookings/lib/handleNewBooking/validateBookingTimeIsNotOutOfBounds.ts b/packages/features/bookings/lib/handleNewBooking/validateBookingTimeIsNotOutOfBounds.ts index d17f39dd94be84..10b1777dd8df6b 100644 --- a/packages/features/bookings/lib/handleNewBooking/validateBookingTimeIsNotOutOfBounds.ts +++ b/packages/features/bookings/lib/handleNewBooking/validateBookingTimeIsNotOutOfBounds.ts @@ -1,6 +1,7 @@ import type { Logger } from "tslog"; import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns"; +import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds"; import type { EventType } from "@calcom/prisma/client"; @@ -15,6 +16,7 @@ type ValidateBookingTimeEventType = Pick< | "minimumBookingNotice" | "eventName" | "id" + | "title" >; export const validateBookingTimeIsNotOutOfBounds = async ( @@ -41,22 +43,14 @@ export const validateBookingTimeIsNotOutOfBounds = async Date: Wed, 29 Jan 2025 14:12:19 +0000 Subject: [PATCH 03/34] fix:clear cancellation reason on other select (#18982) --- apps/web/components/booking/CancelBooking.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx index 100b40e24c3d6d..42275c6699ab98 100644 --- a/apps/web/components/booking/CancelBooking.tsx +++ b/apps/web/components/booking/CancelBooking.tsx @@ -15,11 +15,13 @@ interface InternalNotePresetsSelectProps { label: string; } | null ) => void; + setCancellationReason: (reason: string) => void; } const InternalNotePresetsSelect = ({ internalNotePresets, onPresetSelect, + setCancellationReason, }: InternalNotePresetsSelectProps) => { const { t } = useLocale(); const [showOtherInput, setShowOtherInput] = useState(false); @@ -31,6 +33,7 @@ const InternalNotePresetsSelect = ({ const handleSelectChange = (option: { value: number | string; label: string } | null) => { if (option?.value === "other") { setShowOtherInput(true); + setCancellationReason(""); } else { setShowOtherInput(false); onPresetSelect && onPresetSelect(option); @@ -140,6 +143,7 @@ export default function CancelBooking(props: Props) { <> { if (!option) return; From ac8b4a2861c224d792ea27f612f471d057423cf7 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 29 Jan 2025 20:35:27 +0530 Subject: [PATCH 04/34] fix: Wrong username with -{DOMAIN} in case of an autoAcceptEmail when synced through SCIM (#18384) * Okta fixes * Fix username and name in case of sync from Okta * Fix username and name in case of sync from Okta * Add test * cleanup code and add tests --- .../features/auth/lib/next-auth-options.ts | 26 ++-- .../utils/getOrgUsernameFromEmail.test.ts | 27 ++++ .../signup/utils/getOrgUsernameFromEmail.ts | 7 + .../ee/dsync/lib/handleGroupEvents.ts | 6 +- .../features/ee/dsync/lib/handleUserEvents.ts | 6 +- .../lib/users/createUsersAndConnectToOrg.ts | 39 +++--- packages/features/ee/sso/lib/sso.ts | 30 +---- .../server/repository/organization.test.ts | 127 ++++++++++++------ .../lib/server/repository/organization.ts | 19 +++ 9 files changed, 183 insertions(+), 104 deletions(-) create mode 100644 packages/features/auth/signup/utils/getOrgUsernameFromEmail.test.ts diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 82751db5e33a78..c9d3bad441d96b 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -32,6 +32,7 @@ import logger from "@calcom/lib/logger"; import { randomString } from "@calcom/lib/random"; import { safeStringify } from "@calcom/lib/safeStringify"; import { CredentialRepository } from "@calcom/lib/server/repository/credential"; +import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { UserRepository } from "@calcom/lib/server/repository/user"; import slugify from "@calcom/lib/slugify"; @@ -57,20 +58,7 @@ const ORGANIZATIONS_AUTOLINK = const usernameSlug = (username: string) => `${slugify(username)}-${randomString(6).toLowerCase()}`; const getDomainFromEmail = (email: string): string => email.split("@")[1]; -const getVerifiedOrganizationByAutoAcceptEmailDomain = async (domain: string) => { - const existingOrg = await prisma.team.findFirst({ - where: { - organizationSettings: { - isOrganizationVerified: true, - orgAutoAcceptEmail: domain, - }, - }, - select: { - id: true, - }, - }); - return existingOrg?.id; -}; + const loginWithTotp = async (email: string) => `/auth/login?totp=${await (await import("./signJwt")).default({ email })}`; @@ -369,15 +357,17 @@ if (isSAMLLoginEnabled) { const hostedCal = Boolean(HOSTED_CAL_FEATURES); if (hostedCal && email) { const domain = getDomainFromEmail(email); - const organizationId = await getVerifiedOrganizationByAutoAcceptEmailDomain(domain); - if (organizationId) { + const org = await OrganizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain(domain); + if (org) { const createUsersAndConnectToOrgProps = { emailsToCreate: [email], - organizationId, identityProvider: IdentityProvider.SAML, identityProviderId: email, }; - await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + await createUsersAndConnectToOrg({ + createUsersAndConnectToOrgProps, + org, + }); user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email: email, }); diff --git a/packages/features/auth/signup/utils/getOrgUsernameFromEmail.test.ts b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.test.ts new file mode 100644 index 00000000000000..62aa8aed79b13b --- /dev/null +++ b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { getOrgUsernameFromEmail, deriveNameFromOrgUsername } from "./getOrgUsernameFromEmail"; + +describe("getOrgUsernameFromEmail", () => { + it("should generate username with only email user part when domain matches autoAcceptEmailDomain", () => { + const email = "john.doe@example.com"; + const autoAcceptEmailDomain = "example.com"; + const result = getOrgUsernameFromEmail(email, autoAcceptEmailDomain); + expect(result).toBe("john.doe"); + }); + + it("should generate username with email user and domain when domain doesn't match autoAcceptEmailDomain", () => { + const email = "john.doe@example.com"; + const autoAcceptEmailDomain = "different.com"; + const result = getOrgUsernameFromEmail(email, autoAcceptEmailDomain); + expect(result).toBe("john.doe-example"); + }); +}); + +describe("deriveNameFromOrgUsername", () => { + it("should convert hyphenated username to capitalized words", () => { + const username = "john-doe-example"; + const result = deriveNameFromOrgUsername({ username }); + expect(result).toBe("John Doe Example"); + }); +}); diff --git a/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts index 0e6b4a8fff0e5b..ea629341b1bb14 100644 --- a/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts +++ b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts @@ -9,3 +9,10 @@ export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: st return username; }; + +export const deriveNameFromOrgUsername = ({ username }: { username: string }) => { + return username + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +}; diff --git a/packages/features/ee/dsync/lib/handleGroupEvents.ts b/packages/features/ee/dsync/lib/handleGroupEvents.ts index 1dcd2981fa778c..ab8a46a242935c 100644 --- a/packages/features/ee/dsync/lib/handleGroupEvents.ts +++ b/packages/features/ee/dsync/lib/handleGroupEvents.ts @@ -100,11 +100,13 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb if (newUserEmails.length) { const createUsersAndConnectToOrgProps = { emailsToCreate: newUserEmails, - organizationId: org.id, identityProvider: IdentityProvider.CAL, identityProviderId: null, }; - const newUsers = await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + const newUsers = await createUsersAndConnectToOrg({ + createUsersAndConnectToOrgProps, + org, + }); await prisma.membership.createMany({ data: newUsers.map((user) => ({ userId: user.id, diff --git a/packages/features/ee/dsync/lib/handleUserEvents.ts b/packages/features/ee/dsync/lib/handleUserEvents.ts index 9db8791ff2ec0f..e4ee5877185ccb 100644 --- a/packages/features/ee/dsync/lib/handleUserEvents.ts +++ b/packages/features/ee/dsync/lib/handleUserEvents.ts @@ -121,11 +121,13 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe } else { const createUsersAndConnectToOrgProps = { emailsToCreate: [userEmail], - organizationId: org.id, identityProvider: IdentityProvider.CAL, identityProviderId: null, }; - await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + await createUsersAndConnectToOrg({ + createUsersAndConnectToOrgProps, + org, + }); await sendSignupToOrganizationEmail({ usernameOrEmail: userEmail, diff --git a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts index 37710c12bc160c..aee695057f7aa6 100644 --- a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts +++ b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts @@ -1,32 +1,39 @@ import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships"; -import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; import type { IdentityProvider } from "@calcom/prisma/enums"; import { CreationSource } from "@calcom/prisma/enums"; +import { + deriveNameFromOrgUsername, + getOrgUsernameFromEmail, +} from "../../../../auth/signup/utils/getOrgUsernameFromEmail"; import dSyncUserSelect from "./dSyncUserSelect"; type createUsersAndConnectToOrgPropsType = { emailsToCreate: string[]; - organizationId: number; identityProvider: IdentityProvider; identityProviderId: string | null; }; -const createUsersAndConnectToOrg = async ( - createUsersAndConnectToOrgProps: createUsersAndConnectToOrgPropsType -) => { - const { emailsToCreate, organizationId, identityProvider, identityProviderId } = - createUsersAndConnectToOrgProps; +export const createUsersAndConnectToOrg = async ({ + createUsersAndConnectToOrgProps, + org, +}: { + createUsersAndConnectToOrgProps: createUsersAndConnectToOrgPropsType; + org: { + id: number; + organizationSettings: { + orgAutoAcceptEmail: string | null; + } | null; + }; +}) => { + const { emailsToCreate, identityProvider, identityProviderId } = createUsersAndConnectToOrgProps; + // As of Mar 2024 Prisma createMany does not support nested creates and returning created records await prisma.user.createMany({ data: emailsToCreate.map((email) => { - const [emailUser, emailDomain] = email.split("@"); - const username = slugify(`${emailUser}-${emailDomain.split(".")[0]}`); - const name = username - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); + const username = getOrgUsernameFromEmail(email, org.organizationSettings?.orgAutoAcceptEmail ?? null); + const name = deriveNameFromOrgUsername({ username }); return { username, email, @@ -34,8 +41,8 @@ const createUsersAndConnectToOrg = async ( // Assume verified since coming from directory verified: true, emailVerified: new Date(), - invitedTo: organizationId, - organizationId, + invitedTo: org.id, + organizationId: org.id, identityProvider, identityProviderId, creationSource: CreationSource.WEBAPP, @@ -56,7 +63,7 @@ const createUsersAndConnectToOrg = async ( await createOrUpdateMemberships({ user, team: { - id: organizationId, + id: org.id, isOrganization: true, parentId: null, // orgs don't have a parentId }, diff --git a/packages/features/ee/sso/lib/sso.ts b/packages/features/ee/sso/lib/sso.ts index 18b8839b3c751a..5ad5d3d561b92c 100644 --- a/packages/features/ee/sso/lib/sso.ts +++ b/packages/features/ee/sso/lib/sso.ts @@ -1,5 +1,6 @@ import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg"; import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; +import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; import type { PrismaClient } from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; import { TRPCError } from "@calcom/trpc/server"; @@ -21,26 +22,6 @@ const getAllAcceptedMemberships = async ({ prisma, email }: { prisma: PrismaClie }); }; -const getVerifiedOrganizationByAutoAcceptEmailDomain = async ({ - prisma, - domain, -}: { - prisma: PrismaClient; - domain: string; -}) => { - return await prisma.team.findFirst({ - where: { - organizationSettings: { - isOrganizationVerified: true, - orgAutoAcceptEmail: domain, - }, - }, - select: { - id: true, - }, - }); -}; - export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { const { connectionController } = await jackson(); @@ -54,7 +35,7 @@ export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { }); const domain = email.split("@")[1]; - const organization = await getVerifiedOrganizationByAutoAcceptEmailDomain({ prisma, domain }); + const organization = await OrganizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain(domain); if (!organization) throw new TRPCError({ @@ -62,15 +43,16 @@ export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { message: "no_account_exists", }); - const organizationId = organization.id; const createUsersAndConnectToOrgProps = { emailsToCreate: [email], - organizationId, identityProvider: IdentityProvider.SAML, identityProviderId: email, }; - await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + await createUsersAndConnectToOrg({ + createUsersAndConnectToOrgProps, + org: organization, + }); memberships = await getAllAcceptedMemberships({ prisma, email }); if (!memberships || memberships.length === 0) diff --git a/packages/lib/server/repository/organization.test.ts b/packages/lib/server/repository/organization.test.ts index 8f4378b9aad501..e23ffda402624d 100644 --- a/packages/lib/server/repository/organization.test.ts +++ b/packages/lib/server/repository/organization.test.ts @@ -2,20 +2,76 @@ import prismock from "../../../../tests/libs/__mocks__/prisma"; import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { Prisma } from "@calcom/prisma/client"; + import { OrganizationRepository } from "./organization"; vi.mock("./teamUtils", () => ({ getParsedTeam: (org: any) => org, })); -describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () => { - beforeEach(async () => { - vi.resetAllMocks(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await prismock.reset(); +async function createOrganization( + data: Prisma.TeamCreateInput & { + organizationSettings: { + create: Prisma.OrganizationSettingsCreateWithoutOrganizationInput; + }; + } +) { + return await prismock.team.create({ + data: { + isOrganization: true, + ...data, + }, + }); +} + +async function createReviewedOrganization({ + name = "Test Org", + orgAutoAcceptEmail, +}: { + name: string; + orgAutoAcceptEmail: string; +}) { + return await createOrganization({ + name, + organizationSettings: { + create: { + orgAutoAcceptEmail, + isOrganizationVerified: true, + isAdminReviewed: true, + }, + }, + }); +} + +async function createTeam({ + name = "Test Team", + orgAutoAcceptEmail, +}: { + name: string; + orgAutoAcceptEmail: string; +}) { + return await prismock.team.create({ + data: { + name, + isOrganization: false, + organizationSettings: { + create: { + orgAutoAcceptEmail, + }, + }, + }, }); +} +beforeEach(async () => { + vi.resetAllMocks(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await prismock.reset(); +}); + +describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () => { it("should return null if no organization matches the email domain", async () => { const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com", @@ -67,44 +123,31 @@ describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () = }); }); -async function createReviewedOrganization({ - name = "Test Org", - orgAutoAcceptEmail, -}: { - name: string; - orgAutoAcceptEmail: string; -}) { - return await prismock.team.create({ - data: { - name, - isOrganization: true, +describe("Organization.getVerifiedOrganizationByAutoAcceptEmailDomain", () => { + it("should return organization when domain matches and organization is verified", async () => { + const verifiedOrganization = await createOrganization({ + name: "Test Org", + organizationSettings: { create: { orgAutoAcceptEmail: "cal.com", isOrganizationVerified: true } }, + }); + + const result = await OrganizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain("cal.com"); + + expect(result).toEqual({ + id: verifiedOrganization.id, organizationSettings: { - create: { - orgAutoAcceptEmail, - isOrganizationVerified: true, - isAdminReviewed: true, - }, + orgAutoAcceptEmail: "cal.com", }, - }, + }); }); -} -async function createTeam({ - name = "Test Team", - orgAutoAcceptEmail, -}: { - name: string; - orgAutoAcceptEmail: string; -}) { - return await prismock.team.create({ - data: { - name, - isOrganization: false, - organizationSettings: { - create: { - orgAutoAcceptEmail, - }, - }, - }, + it("should not return organization when organization is not verified", async () => { + await createOrganization({ + name: "Test Org", + organizationSettings: { create: { orgAutoAcceptEmail: "cal.com", isOrganizationVerified: false } }, + }); + + const result = await OrganizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain("cal.com"); + + expect(result).toEqual(null); }); -} +}); diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 43725c71a4a341..752e19b1f16cb7 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -354,4 +354,23 @@ export class OrganizationRepository { return org?.calVideoLogo; } + + static async getVerifiedOrganizationByAutoAcceptEmailDomain(domain: string) { + return await prisma.team.findFirst({ + where: { + organizationSettings: { + isOrganizationVerified: true, + orgAutoAcceptEmail: domain, + }, + }, + select: { + id: true, + organizationSettings: { + select: { + orgAutoAcceptEmail: true, + }, + }, + }, + }); + } } From be1b9d1a79c5ee49ada82dc2a7d63fe94e05dbf3 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Wed, 29 Jan 2025 21:26:23 +0530 Subject: [PATCH 05/34] feat: skip confirm step in booker (#18773) * feat: skip confirm step * better naming * disable on loading * feat: added cloudflare turnstile captcha to booker * Update Booker.tsx * Update AvailableTimeSlots.tsx * made optional to fix type errors * Update Booker.tsx * Update getBookingResponsesSchema.ts * Update Booker.tsx * fixed failing tests * added tests * fix: fixed failing embed tests --- apps/web/playwright/booking-pages.e2e.ts | 17 ++ .../manage-booking-questions.e2e.ts | 2 - packages/embeds/embed-core/playground.ts | 2 - packages/features/bookings/Booker/Booker.tsx | 55 +++++- .../Booker/components/AvailableTimeSlots.tsx | 23 ++- .../BookEventForm/BookEventForm.tsx | 29 +-- .../components/hooks/useSkipConfirmStep.ts | 43 +++++ .../bookings/components/AvailableTimes.tsx | 170 +++++++++++------- .../bookings/lib/getBookingResponsesSchema.ts | 19 +- .../hooks/bookings/useHandleBookEvent.ts | 5 +- 10 files changed, 251 insertions(+), 114 deletions(-) create mode 100644 packages/features/bookings/Booker/components/hooks/useSkipConfirmStep.ts diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 5d83253fd27b9c..e10bc3fa33ad13 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -401,6 +401,23 @@ test.describe("prefill", () => { await expect(page.locator('[name="email"]')).toHaveValue(testEmail); }); }); + + test("skip confirm step if all fields are prefilled from query params", async ({ page }) => { + await page.goto("/pro/30min"); + const url = new URL(page.url()); + url.searchParams.set("name", testName); + url.searchParams.set("email", testEmail); + url.searchParams.set("guests", "guest1@example.com"); + url.searchParams.set("guests", "guest2@example.com"); + url.searchParams.set("notes", "This is an additional note"); + await page.goto(url.toString()); + await selectFirstAvailableTimeSlotNextMonth(page); + + await expect(page.locator('[data-testid="skip-confirm-book-button"]')).toBeVisible(); + await page.click('[data-testid="skip-confirm-book-button"]'); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + }); }); test.describe("Booking on different layouts", () => { diff --git a/apps/web/playwright/manage-booking-questions.e2e.ts b/apps/web/playwright/manage-booking-questions.e2e.ts index 3a040c118be8f4..9443f13f1b7a71 100644 --- a/apps/web/playwright/manage-booking-questions.e2e.ts +++ b/apps/web/playwright/manage-booking-questions.e2e.ts @@ -172,7 +172,6 @@ test.describe("Manage Booking Questions", () => { prefillUrl.searchParams.append("email", "john@example.com"); prefillUrl.searchParams.append("guests", "guest1@example.com"); prefillUrl.searchParams.append("guests", "guest2@example.com"); - prefillUrl.searchParams.append("notes", "This is an additional note"); await page.goto(prefillUrl.toString()); await bookTimeSlot({ page, skipSubmission: true }); await expectSystemFieldsToBeThereOnBookingPage({ @@ -185,7 +184,6 @@ test.describe("Manage Booking Questions", () => { }, email: "john@example.com", guests: ["guest1@example.com", "guest2@example.com"], - notes: "This is an additional note", }, }); }); diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index 74b51218f8da99..f1f71d85848746 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -63,7 +63,6 @@ if (only === "all" || only === "ns:default") { }, name: "John", email: "johndoe@gmail.com", - notes: "Test Meeting", guests: ["janedoe@example.com", "test@example.com"], theme: "dark", "flag.coep": "true", @@ -454,7 +453,6 @@ if (only === "all" || only == "ns:floatingButton") { "flag.coep": "true", name: "John", email: "johndoe@gmail.com", - notes: "Test Meeting", guests: ["janedoe@example.com", "test@example.com"], ...(theme ? { theme } : {}), }, diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 827d498ef2676c..582a3669deabbd 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -8,9 +8,11 @@ import { shallow } from "zustand/shallow"; import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; import { useIsPlatformBookerEmbed } from "@calcom/atoms/monorepo"; import dayjs from "@calcom/dayjs"; +import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep"; import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules"; import classNames from "@calcom/lib/classNames"; +import { CLOUDFLARE_SITE_ID, CLOUDFLARE_USE_TURNSTILE_IN_BOOKER } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; @@ -33,6 +35,8 @@ import { useBookerStore } from "./store"; import type { BookerProps, WrappedBookerProps } from "./types"; import { isBookingDryRun } from "./utils/isBookingDryRun"; +const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false }); + const loadFramerFeatures = () => import("./framer-features").then((res) => res.default); const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy").then((mod) => mod.default)); const UnpublishedEntity = dynamic(() => @@ -78,6 +82,7 @@ const BookerComponent = ({ const searchParams = useCompatSearchParams(); const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); + const selectedDate = useBookerStore((state) => state.selectedDate); const { shouldShowFormInDialog, @@ -128,6 +133,8 @@ const BookerComponent = ({ const { handleBookEvent, errors, loadingStates, expiryTime, instantVideoMeetingUrl } = bookings; + const watchedCfToken = bookingForm.watch("cfToken"); + const { isEmailVerificationModalVisible, setEmailVerificationModalVisible, @@ -153,29 +160,46 @@ const BookerComponent = ({ } }; + const skipConfirmStep = useSkipConfirmStep(bookingForm, event?.data?.bookingFields); + + // Cloudflare Turnstile Captcha + const shouldRenderCaptcha = !!( + !process.env.NEXT_PUBLIC_IS_E2E && + renderCaptcha && + CLOUDFLARE_SITE_ID && + CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1" && + (bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep)) + ); + useEffect(() => { if (event.isPending) return setBookerState("loading"); if (!selectedDate) return setBookerState("selecting_date"); - if (!selectedTimeslot) return setBookerState("selecting_time"); + if (!selectedTimeslot || skipConfirmStep) return setBookerState("selecting_time"); return setBookerState("booking"); - }, [event, selectedDate, selectedTimeslot, setBookerState]); + }, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep]); const slot = getQueryParam("slot"); + useEffect(() => { setSelectedTimeslot(slot || null); }, [slot, setSelectedTimeslot]); + + const onSubmit = (timeSlot?: string) => { + renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail(); + }; + const EventBooker = useMemo(() => { return bookerState === "booking" ? ( { setSelectedTimeslot(null); if (seatedEventData.bookingUid) { setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined }); } }} - onSubmit={renderConfirmNotVerifyEmailButtonCond ? handleBookEvent : handleVerifyEmail} + onSubmit={() => (renderConfirmNotVerifyEmailButtonCond ? handleBookEvent() : handleVerifyEmail())} errorRef={bookerFormErrorRef} errors={{ ...formErrors, ...errors }} loadingStates={loadingStates} @@ -249,6 +273,8 @@ const BookerComponent = ({ verifyCode?.verifyCodeWithSessionNotRequired, verifyCode?.verifyCodeWithSessionRequired, isPlatform, + shouldRenderCaptcha, + isVerificationCodeSending, ]); /** @@ -442,6 +468,13 @@ const BookerComponent = ({ seatsPerTimeSlot={event.data?.seatsPerTimeSlot} showAvailableSeatsCount={event.data?.seatsShowAvailabilityCount} event={event} + loadingStates={loadingStates} + renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond} + isVerificationCodeSending={isVerificationCodeSending} + onSubmit={onSubmit} + skipConfirmStep={skipConfirmStep} + shouldRenderCaptcha={shouldRenderCaptcha} + watchedCfToken={watchedCfToken} /> @@ -472,7 +505,19 @@ const BookerComponent = ({ /> )} - {!hideBranding && (!isPlatform || isPlatformBookerEmbed) && ( + + {shouldRenderCaptcha && ( +
+ { + bookingForm.setValue("cfToken", token); + }} + /> +
+ )} + + {!hideBranding && (!isPlatform || isPlatformBookerEmbed) && !shouldRenderCaptcha && ( | null; + data?: Pick | null; }; customClassNames?: { availableTimeSlotsContainer?: string; @@ -29,6 +30,13 @@ type AvailableTimeSlotsProps = { availableTimeSlotsTimeFormatToggle?: string; availableTimes?: string; }; + loadingStates: IUseBookingLoadingStates; + isVerificationCodeSending: boolean; + renderConfirmNotVerifyEmailButtonCond: boolean; + onSubmit: (timeSlot?: string) => void; + skipConfirmStep: boolean; + shouldRenderCaptcha?: boolean; + watchedCfToken?: string; }; /** @@ -38,15 +46,17 @@ type AvailableTimeSlotsProps = { * will also fetch the next `extraDays` days and show multiple days * in columns next to each other. */ + export const AvailableTimeSlots = ({ extraDays, limitHeight, - seatsPerTimeSlot, showAvailableSeatsCount, schedule, isLoading, - event, customClassNames, + skipConfirmStep, + onSubmit, + ...props }: AvailableTimeSlotsProps) => { const selectedDate = useBookerStore((state) => state.selectedDate); const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); @@ -71,6 +81,9 @@ export const AvailableTimeSlots = ({ showAvailableSeatsCount, }); } + if (skipConfirmStep) { + onSubmit(time); + } return; }; @@ -135,9 +148,9 @@ export const AvailableTimeSlots = ({ showTimeFormatToggle={!isColumnView} onTimeSelect={onTimeSelect} slots={slots.slots} - seatsPerTimeSlot={seatsPerTimeSlot} showAvailableSeatsCount={showAvailableSeatsCount} - event={event} + skipConfirmStep={skipConfirmStep} + {...props} /> ))} diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index 4714aa72da91d9..60ab42fe282f71 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -1,18 +1,12 @@ import type { TFunction } from "next-i18next"; import { Trans } from "next-i18next"; -import dynamic from "next/dynamic"; import Link from "next/link"; import { useMemo, useState } from "react"; import type { FieldError } from "react-hook-form"; import { useIsPlatformBookerEmbed } from "@calcom/atoms/monorepo"; import type { BookerEvent } from "@calcom/features/bookings/types"; -import { - WEBSITE_PRIVACY_POLICY_URL, - WEBSITE_TERMS_URL, - CLOUDFLARE_SITE_ID, - CLOUDFLARE_USE_TURNSTILE_IN_BOOKER, -} from "@calcom/lib/constants"; +import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants"; import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Alert, Button, EmptyScreen, Form } from "@calcom/ui"; @@ -23,8 +17,6 @@ import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBo import { BookingFields } from "./BookingFields"; import { FormSkeleton } from "./Skeleton"; -const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false }); - type BookEventFormProps = { onCancel?: () => void; onSubmit: () => void; @@ -37,7 +29,7 @@ type BookEventFormProps = { extraOptions: Record; isPlatform?: boolean; isVerificationCodeSending: boolean; - renderCaptcha?: boolean; + shouldRenderCaptcha?: boolean; }; export const BookEventForm = ({ @@ -54,7 +46,7 @@ export const BookEventForm = ({ extraOptions, isVerificationCodeSending, isPlatform = false, - renderCaptcha, + shouldRenderCaptcha, }: Omit & { eventQuery: { isError: boolean; @@ -71,13 +63,6 @@ export const BookEventForm = ({ const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); - // Cloudflare Turnstile Captcha - const shouldRenderCaptcha = - !process.env.NEXT_PUBLIC_IS_E2E && - renderCaptcha && - CLOUDFLARE_SITE_ID && - CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1"; - const [responseVercelIdHeader] = useState(null); const { t } = useLocale(); @@ -140,14 +125,6 @@ export const BookEventForm = ({ )} {/* Cloudflare Turnstile Captcha */} - {shouldRenderCaptcha ? ( - { - bookingForm.setValue("cfToken", token); - }} - /> - ) : null} {!isPlatform && (
{ + const bookingFormValues = bookingForm.getValues(); + + const [canSkip, setCanSkip] = useState(false); + const rescheduleUid = useBookerStore((state) => state.rescheduleUid); + + useEffect(() => { + const checkSkipStep = async () => { + if (!bookingFields) { + setCanSkip(false); + return; + } + + try { + const responseSchema = getBookingResponsesSchemaWithOptionalChecks({ + bookingFields, + view: rescheduleUid ? "reschedule" : "booking", + }); + const responseSafeParse = await responseSchema.safeParseAsync(bookingFormValues.responses); + + setCanSkip(responseSafeParse.success); + } catch (error) { + setCanSkip(false); + } + }; + + checkSkipStep(); + }, [bookingFormValues, bookingFields, rescheduleUid]); + + return canSkip; +}; + +export default useSkipConfirmStep; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 61cb28ec0e5af8..238f7dd739affd 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -1,15 +1,17 @@ // We do not need to worry about importing framer-motion here as it is lazy imported in Booker. import * as HoverCard from "@radix-ui/react-hover-card"; import { AnimatePresence, m } from "framer-motion"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useIsPlatform } from "@calcom/atoms/monorepo"; import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability"; import dayjs from "@calcom/dayjs"; import { OutOfOfficeInSlots } from "@calcom/features/bookings/Booker/components/OutOfOfficeInSlots"; +import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import type { BookerEvent } from "@calcom/features/bookings/types"; import type { Slots } from "@calcom/features/schedules"; import { classNames } from "@calcom/lib"; +import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; import type { IGetAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util"; @@ -28,18 +30,28 @@ type TOnTimeSelect = ( bookingUid?: string ) => void; -type AvailableTimesProps = { +export type AvailableTimesProps = { slots: IGetAvailableSlots["slots"][string]; - onTimeSelect: TOnTimeSelect; - seatsPerTimeSlot?: number | null; - showAvailableSeatsCount?: boolean | null; showTimeFormatToggle?: boolean; className?: string; +} & Omit; + +type SlotItemProps = { + slot: Slots[string][number]; + seatsPerTimeSlot?: number | null; selectedSlots?: string[]; + onTimeSelect: TOnTimeSelect; + showAvailableSeatsCount?: boolean | null; event: { - data?: Pick | null; + data?: Pick | null; }; customClassNames?: string; + loadingStates?: IUseBookingLoadingStates; + isVerificationCodeSending?: boolean; + renderConfirmNotVerifyEmailButtonCond?: boolean; + skipConfirmStep?: boolean; + shouldRenderCaptcha?: boolean; + watchedCfToken?: string; }; const SlotItem = ({ @@ -50,25 +62,29 @@ const SlotItem = ({ showAvailableSeatsCount, event, customClassNames, -}: { - slot: Slots[string][number]; - seatsPerTimeSlot?: number | null; - selectedSlots?: string[]; - onTimeSelect: TOnTimeSelect; - showAvailableSeatsCount?: boolean | null; - event: { - data?: Pick | null; - }; - customClassNames?: string; -}) => { + loadingStates, + renderConfirmNotVerifyEmailButtonCond, + isVerificationCodeSending, + skipConfirmStep, + shouldRenderCaptcha, + watchedCfToken, +}: SlotItemProps) => { const { t } = useLocale(); + const { data: eventData } = event; + + const isPaidEvent = useMemo(() => { + if (!eventData?.price) return false; + const paymentAppData = getPaymentAppData(eventData); + return eventData?.price > 0 && !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0; + }, [eventData]); + const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault"); + const { timeFormat, timezone } = useBookerTime(); const bookingData = useBookerStore((state) => state.bookingData); const layout = useBookerStore((state) => state.layout); - const { data: eventData } = event; const hasTimeSlots = !!seatsPerTimeSlot; const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone); @@ -82,35 +98,32 @@ const SlotItem = ({ const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60; + const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot); + const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay({ start: computedDateWithUsersTimezone, selectedDuration: eventData?.length ?? 0, offset, }); - const [overlapConfirm, setOverlapConfirm] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); const onButtonClick = useCallback(() => { - if (!overlayCalendarToggled || (isOverlapping && overlapConfirm)) { - onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); + if (!showConfirm && ((overlayCalendarToggled && isOverlapping) || skipConfirmStep)) { + setShowConfirm(true); return; } - - if (isOverlapping) { - setOverlapConfirm(true); - return; - } - onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); }, [ overlayCalendarToggled, isOverlapping, - overlapConfirm, + showConfirm, onTimeSelect, slot.time, slot?.attendees, slot.bookingUid, seatsPerTimeSlot, + skipConfirmStep, ]); return ( @@ -118,7 +131,15 @@ const SlotItem = ({
- {overlapConfirm && isOverlapping && ( + {showConfirm && ( - + {skipConfirmStep ? ( + + ) : ( + + )} - - -
-
-

Busy

+ {isOverlapping && ( + + +
+
+

Busy

+
+

+ {overlappingTimeStart} - {overlappingTimeEnd} +

-

- {overlappingTimeStart} - {overlappingTimeEnd} -

-
- - + + + )} )}
@@ -190,14 +242,9 @@ const SlotItem = ({ export const AvailableTimes = ({ slots, - onTimeSelect, - seatsPerTimeSlot, - showAvailableSeatsCount, showTimeFormatToggle = true, className, - selectedSlots, - event, - customClassNames, + ...props }: AvailableTimesProps) => { const { t } = useLocale(); @@ -226,18 +273,7 @@ export const AvailableTimes = ({ {oooBeforeSlots && !oooAfterSlots && } {slots.map((slot) => { if (slot.away) return null; - return ( - - ); + return ; })} {oooAfterSlots && !oooBeforeSlots && }
diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 9961ffb8df26b5..24fff7905c51ac 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -34,6 +34,12 @@ export default function getBookingResponsesSchema({ bookingFields, view }: Commo return preprocess({ schema, bookingFields, isPartialSchema: false, view }); } +// Should be used when we want to check if the optional fields are entered and valid as well +export function getBookingResponsesSchemaWithOptionalChecks({ bookingFields, view }: CommonParams) { + const schema = bookingResponses.and(z.record(z.any())); + return preprocess({ schema, bookingFields, isPartialSchema: false, view, checkOptional: true }); +} + // TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder // It allows anyone using FormBuilder to get the same preprocessing automatically function preprocess({ @@ -41,12 +47,14 @@ function preprocess({ bookingFields, isPartialSchema, view: currentView, + checkOptional = false, }: CommonParams & { schema: T; // It is useful when we want to prefill the responses with the partial values. Partial can be in 2 ways // - Not all required fields are need to be provided for prefill. // - Even a field response itself can be partial so the content isn't validated e.g. a field with type="phone" can be given a partial phone number(e.g. Specifying the country code like +91) isPartialSchema: boolean; + checkOptional?: boolean; }): z.ZodType, z.infer, z.infer> { const preprocessed = z.preprocess( (responses) => { @@ -149,8 +157,11 @@ function preprocess({ if (bookingField.hideWhenJustOneOption) { hidden = hidden || numOptions <= 1; } + let isRequired = false; // If the field is hidden, then it can never be required - const isRequired = hidden ? false : isFieldApplicableToCurrentView ? bookingField.required : false; + if (!hidden && isFieldApplicableToCurrentView) { + isRequired = checkOptional || !!bookingField.required; + } if ((isPartialSchema || !isRequired) && value === undefined) { continue; @@ -162,7 +173,7 @@ function preprocess({ } if (bookingField.type === "email") { - if (!bookingField.hidden && bookingField.required) { + if (!bookingField.hidden && checkOptional ? true : bookingField.required) { // Email RegExp to validate if the input is a valid email if (!emailSchema.safeParse(value).success) { ctx.addIssue({ @@ -285,9 +296,7 @@ function preprocess({ const typeOfOptionInput = optionField?.type; if ( // Either the field is required or there is a radio selected, we need to check if the optionInput is required or not. - (isRequired || value?.value) && - optionField?.required && - !optionValue + (isRequired || value?.value) && checkOptional ? true : optionField?.required && !optionValue ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("error_required_field") }); return; diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index 3bef4e4dc78c51..830773fbf5a470 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -39,7 +39,7 @@ export const useHandleBookEvent = ({ routingFormSearchParams, }: UseHandleBookingProps) => { const setFormValues = useBookerStore((state) => state.setFormValues); - const timeslot = useBookerStore((state) => state.selectedTimeslot); + const storeTimeSlot = useBookerStore((state) => state.selectedTimeslot); const duration = useBookerStore((state) => state.selectedDuration); const { timezone } = useBookerTime(); const rescheduleUid = useBookerStore((state) => state.rescheduleUid); @@ -55,8 +55,9 @@ export const useHandleBookEvent = ({ const crmOwnerRecordType = useBookerStore((state) => state.crmOwnerRecordType); const crmAppSlug = useBookerStore((state) => state.crmAppSlug); - const handleBookEvent = () => { + const handleBookEvent = (inputTimeSlot?: string) => { const values = bookingForm.getValues(); + const timeslot = inputTimeSlot ?? storeTimeSlot; if (timeslot) { // Clears form values stored in store, so old values won't stick around. setFormValues({}); From 5c98861f6f3d96a7af1eab806743d55c2589eab1 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 29 Jan 2025 17:41:53 +0100 Subject: [PATCH 06/34] fix: re-design DateRangeFilter (#18924) * fix: re-design DateRangeFilter * update style * adjust padding --- .../components/filters/DateRangeFilter.tsx | 104 +++++++++++------- .../date-range-picker/DateRangePicker.tsx | 34 ++++-- 2 files changed, 88 insertions(+), 50 deletions(-) diff --git a/packages/features/data-table/components/filters/DateRangeFilter.tsx b/packages/features/data-table/components/filters/DateRangeFilter.tsx index 104b43152d1356..9e0b199fa1edcc 100644 --- a/packages/features/data-table/components/filters/DateRangeFilter.tsx +++ b/packages/features/data-table/components/filters/DateRangeFilter.tsx @@ -1,13 +1,22 @@ +import { format } from "date-fns"; import type { Dayjs } from "dayjs"; import { useState } from "react"; import dayjs from "@calcom/dayjs"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { DateRangePicker } from "@calcom/ui"; -import { Select } from "@calcom/ui"; +import { + DateRangePicker, + Button, + Icon, + Popover, + PopoverContent, + PopoverTrigger, + Command, + CommandList, + CommandItem, +} from "@calcom/ui"; -import "../../../insights/filters/DateSelect.css"; import { useDataTable, useFilterValue } from "../../hooks"; import type { FilterableColumn } from "../../lib/types"; import { ZDateRangeFilterValue, ColumnFilterType } from "../../lib/types"; @@ -19,7 +28,6 @@ type PresetOption = { }; type DateRangeFilterProps = { - className?: string; column: Extract; }; @@ -83,7 +91,7 @@ const getDefaultStartDate = () => dayjs().subtract(1, "week").startOf("day"); const getDefaultEndDate = () => dayjs().endOf("day"); -export const DateRangeFilter = ({ className, column }: DateRangeFilterProps) => { +export const DateRangeFilter = ({ column }: DateRangeFilterProps) => { const filterValue = useFilterValue(column.id, ZDateRangeFilterValue); const { updateFilter } = useDataTable(); @@ -153,39 +161,59 @@ export const DateRangeFilter = ({ className, column }: DateRangeFilterProps) => }); }; + const isCustomPreset = selectedPreset.value === CUSTOM_PRESET_VALUE; + return ( -
- -