diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index bbea556700..99448e1f5e 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 2ae61c8653..675b94cf69 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 78c2c728c7..7ead58090f 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 726fcd840d..827d498ef2 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 6b1794d10c..d5703b319f 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 0000000000..76d1fbba07 --- /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 977b85b215..a699475f37 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 2f9bcdaf45..7648d22fcc 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 0dbcbbcaf5..3bef4e4dc7 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 dc5e1ab9d6..f5bf70425f 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 806dbcdcf7..8e611b67dc 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 0000000000..11b855a489 --- /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 0000000000..19f100fac3 --- /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 fa67e54067..c826006aca 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 0000000000..ab52fc7d2d --- /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 0000000000..3357d00020 --- /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 4ba50e7540..9b23abb356 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 3a819f5cbf..693c5a2942 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 {