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 <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Rajiv Sahal
2025-01-29 13:51:58 +02:00
committed by GitHub
co-authored by Morgan Vernay Morgan
parent 9608669f98
commit 8615ccb1aa
21 changed files with 546 additions and 171 deletions
+1 -1
View File
@@ -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": "*",
@@ -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;
}
>;
}
@@ -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<string, string>
): Promise<void | ApiResponse<unknown>> {
const routedUrlData = await getRoutedUrl({ req: request, query: { ...query, form: formId } });
@Body() body?: Record<string, string>
): Promise<void | (ApiResponse<unknown> & { 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 };
}
}
+4 -2
View File
@@ -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 ? <BookingPageTagManager eventType={event.data} /> : <></>}
{isBookingDryRun(searchParams) && <DryRunMessage isEmbed={isEmbed} />}
{(isBookingDryRunProp || isBookingDryRun(searchParams)) && <DryRunMessage isEmbed={isEmbed} />}
<div
className={classNames(
// In a popup embed, if someone clicks outside the main(having main class or main tag), it closes the embed
"main",
"text-default flex min-h-full w-full flex-col items-center",
layout === BookerLayouts.MONTH_VIEW ? "overflow-visible" : "overflow-clip"
layout === BookerLayouts.MONTH_VIEW ? "overflow-visible" : "overflow-clip",
`${customClassNames?.bookerWrapper}`
)}>
<div
ref={animationScope}
@@ -118,6 +118,7 @@ export type WrappedBookerPropsMain = {
bookerLayout: UseBookerLayoutType;
verifyEmail: UseVerifyEmailReturnType;
customClassNames?: CustomClassNames;
isBookingDryRun?: boolean;
renderCaptcha?: boolean;
};
@@ -138,6 +139,7 @@ export type BookerLayout = BookerLayouts | "mobile";
export type BookerAreas = "calendar" | "timeslots" | "main" | "meta" | "header";
export type CustomClassNames = {
bookerWrapper?: string;
bookerContainer?: string;
eventMetaCustomClassNames?: {
eventMetaContainer?: string;
@@ -4,6 +4,7 @@ import dayjs from "@calcom/dayjs";
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
import { parseRecurringDates } from "@calcom/lib/parse-dates";
import type { RoutingFormSearchParams } from "@calcom/platform-types";
import type { BookerEvent, BookingCreateBody, RecurringBookingCreateBody } from "../../types";
@@ -26,6 +27,7 @@ export type BookingOptions = {
crmOwnerRecordType?: string | null;
crmAppSlug?: string | null;
orgSlug?: string;
routingFormSearchParams?: RoutingFormSearchParams;
};
export const mapBookingToMutationInput = ({
@@ -46,8 +48,9 @@ export const mapBookingToMutationInput = ({
crmOwnerRecordType,
crmAppSlug,
orgSlug,
routingFormSearchParams,
}: BookingOptions): BookingCreateBody => {
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;
+4 -2
View File
@@ -171,7 +171,9 @@ export const getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "que
allURLSearchParams: getUrlSearchParamsToForward({
formResponse: response,
fields: serializableForm.fields,
searchParams: new URLSearchParams(stringify(paramsToBeForwardedAsIs)),
searchParams: new URLSearchParams(
stringify({ ...paramsToBeForwardedAsIs, "cal.action": "eventTypeRedirectUrl" })
),
teamMembersMatchingAttributeLogic,
// formResponseId is guaranteed to be set because in catch block of trpc request we return from the function and otherwise it would have been set
formResponseId: formResponseId!,
@@ -187,7 +189,7 @@ export const getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "que
} else if (decidedAction.type === "externalRedirectUrl") {
return {
redirect: {
destination: `${decidedAction.value}?${stringify(context.query)}`,
destination: `${decidedAction.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
permanent: false,
},
};
@@ -4,21 +4,78 @@ import type {
} from "../booker/BookerPlatformWrapper";
import { BookerPlatformWrapper } from "../booker/BookerPlatformWrapper";
import { CalProvider } from "../cal-provider/CalProvider";
import { useGetRoutingFormUrlProps } from "./useGetRoutingFormUrlProps";
export const BookerEmbed = (
props:
| (BookerPlatformWrapperAtomPropsForIndividual & { organizationId?: undefined })
| (BookerPlatformWrapperAtomPropsForTeam & { organizationId?: number })
| {
routingFormUrl: string;
bannerUrl?: BookerPlatformWrapperAtomPropsForTeam["bannerUrl"];
customClassNames?: BookerPlatformWrapperAtomPropsForTeam["customClassNames"];
}
| (BookerPlatformWrapperAtomPropsForIndividual & {
organizationId?: undefined;
routingFormUrl?: undefined;
})
| (BookerPlatformWrapperAtomPropsForTeam & { organizationId?: number; routingFormUrl?: undefined })
) => {
return (
<CalProvider
clientId={import.meta.env.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID}
isEmbed={true}
organizationId={props?.organizationId}
options={{
apiUrl: import.meta.env.VITE_BOOKER_EMBED_API_URL,
}}>
<BookerPlatformWrapper {...props} />
</CalProvider>
);
// 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 (
<CalProvider
clientId={import.meta.env.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID}
isEmbed={true}
organizationId={organizationId}
options={{
apiUrl: import.meta.env.VITE_BOOKER_EMBED_API_URL,
}}>
<BookerPlatformWrapper
{...(Boolean(routingTeamId)
? {
eventSlug: eventTypeSlug,
isTeamEvent: true,
teamId: routingTeamId || 0,
username: "",
customClassNames: props?.customClassNames,
}
: {
eventSlug: eventTypeSlug,
username: username ?? "",
isTeamEvent: false,
customClassNames: props?.customClassNames,
})}
routingFormSearchParams={routingFormSearchParams}
bannerUrl={props.bannerUrl}
onDryRunSuccess={() => {
window.location.href = `https://app.cal.com/booking/dry-run-successful`;
}}
/>
</CalProvider>
);
}
// If Not For From Routing Form, Use Props
if (props?.routingFormUrl === undefined) {
return (
<CalProvider
clientId={import.meta.env.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID}
isEmbed={true}
organizationId={props?.organizationId}
options={{
apiUrl: import.meta.env.VITE_BOOKER_EMBED_API_URL,
}}>
<BookerPlatformWrapper {...props} />
</CalProvider>
);
}
return <></>;
};
@@ -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;
};
@@ -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<string, string>;
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}
/>
</AtomsWrapper>
);
+90 -138
View File
@@ -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)
@@ -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) {
@@ -22,6 +22,9 @@ export const useAvailableSlots = ({
rest.isTeamEvent ?? false,
rest.teamId ?? false,
rest.usernameList,
rest.routedTeamMemberIds,
rest.skipContactOwner,
rest.shouldServeCache,
],
queryFn: () => {
return http
+2
View File
@@ -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";
+127
View File
@@ -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
* <Router
* formId="1a1a1a1a-2b2b-3c3c-4d4d-5e5e5e5e5e5e"
* formResponsesURLParams={new URLSearchParams({ Territory: "Europe" })}
* 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",
* }}
* />
* ```
*/
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<boolean>();
const [routerUrl, setRouterUrl] = useState<string>();
const [routingData, setRoutingData] = useState<{ message: string } | undefined>();
const [isError, setIsError] = useState<boolean>();
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 (
<BookerEmbed
routingFormUrl={routerUrl}
customClassNames={bookerCustomClassNames}
bannerUrl={bookerBannerUrl}
/>
);
} else if (redirectParams.get("cal.action") === "externalRedirectUrl") {
onExternalRedirect?.();
window.location.href = routerUrl;
return <></>;
}
}
if (!isRedirect && routingData?.message) {
if (renderMessage) {
return <>{renderMessage(routingData?.message)}</>;
}
return (
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="text-default bg-default -mx-4 rounded-sm border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
<div>{routingData?.message}</div>
</div>
</div>
</div>
);
}
return <></>;
}
);
Router.displayName = "RouterAtom";
+1
View File
@@ -0,0 +1 @@
export { Router } from "./Router";
@@ -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) {
/>
</div>
)}
{pathname === "/router" && (
<div className="p-4">
<Router
formId="a63e6fce-899a-404e-8c38-e069710589c5"
formResponsesURLParams={new URLSearchParams({ isBookingDryRun: "true", Territory: "Europe" })}
onDisplayBookerEmbed={() => {
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",
}}
/>
</div>
)}
</div>
);
}
@@ -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 (
<main className={`flex ${inter.className} text-default dark flex flex-col`}>
<Navbar username={props.calUsername} />
<div>
<h1 className="mx-8 my-4 text-2xl font-bold">This is the router atom</h1>
</div>
</main>
);
}
+16
View File
@@ -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;
};
+1
View File
@@ -9,3 +9,4 @@ export * from "./schedules";
export * from "./event-types";
export * from "./organizations";
export * from "./teams";
export * from "./embed";
+29 -1
View File
@@ -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 {