feat: add redirect option for non-routed visits to event types (#27468)

* feat: add redirect option for non-routing form bookings

Add a new event type option that redirects to a custom URL when the booking
was not made through a routing form (no cal.routingFormResponseId or
cal.queuedFormResponseId query parameters).

Changes:
- Add redirectUrlOnNoRoutingFormResponse field to EventType schema
- Add UI toggle in Advanced tab to configure the redirect URL
- Implement redirect logic in bookingSuccessRedirect hook
- Add translations for new UI strings

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add redirectUrlOnNoRoutingFormResponse to test builder

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add redirectUrlOnNoRoutingFormResponse to BookerEvent type

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add redirectUrlOnNoRoutingFormResponse to getPublicEventSelect

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add redirect logic to getServerSideProps for non-routing form bookings

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove redirectUrlOnNoRoutingFormResponse from booking success flow

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use 'in' operator for type narrowing on redirectUrlOnNoRoutingFormResponse

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: exclude redirectUrlOnNoRoutingFormResponse from test assertions

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: include redirectUrlOnNoRoutingFormResponse in test assertions and update description

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add redirectUrlOnNoRoutingFormResponse to mockUpdatedEventType

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove redirect logic from instant meetings

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add redirectUrlOnNoRoutingFormResponse to EventTypeRepository select

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add redirectUrlOnNoRoutingFormResponse to form default values

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Reword string

* fix: bust tRPC cache for redirectUrlOnNoRoutingFormResponse

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: guard redirect when rescheduleUid or bookingUid is present

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add redirectUrlOnNoRoutingFormResponse to dynamicEvent defaults

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add bookingUid guard to team getServerSideProps redirect

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-02-02 15:48:23 -03:00
1 parent 7062887632
commit c0f6a1b088
17 files changed
+159 -36

No files matched your search

@@ -62,14 +62,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
session?.user?.id
);
if (!eventData) {
return {
notFound: true,
} as const;
}
if (!eventData) {
return {
notFound: true,
} as const;
}
return {
props: {
return {
props: {
eventData,
entity: eventData.entity,
eventTypeId: eventData.id,
@@ -30,8 +30,8 @@ function hasApiV2RouteInEnv() {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, params, query } = context;
const session = await getServerSession({ req });
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, bookingUid, isInstantMeeting: queryIsInstantMeeting } = query;
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
@@ -55,11 +55,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventData = team.eventTypes[0];
if (eventData.schedulingType === SchedulingType.MANAGED) {
return { notFound: true } as const;
}
if (eventData.schedulingType === SchedulingType.MANAGED) {
return { notFound: true } as const;
}
if (rescheduleUid && eventData.disableRescheduling) {
// Redirect if no routing form response and redirect URL is configured
// Don't redirect if this is a reschedule flow or seated booking flow
const hasRoutingFormResponse = query["cal.routingFormResponseId"] || query["cal.queuedFormResponseId"];
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && eventData.redirectUrlOnNoRoutingFormResponse) {
return {
redirect: {
destination: eventData.redirectUrlOnNoRoutingFormResponse,
permanent: false,
},
};
}
if (rescheduleUid && eventData.disableRescheduling) {
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
}
@@ -239,19 +251,20 @@ const getTeamWithEventsData = async (
where: {
slug: meetingSlug,
},
select: {
id: true,
title: true,
isInstantEvent: true,
schedulingType: true,
metadata: true,
length: true,
hidden: true,
disableCancelling: true,
disableRescheduling: true,
allowReschedulingCancelledBookings: true,
interfaceLanguage: true,
hosts: {
select: {
id: true,
title: true,
isInstantEvent: true,
schedulingType: true,
metadata: true,
length: true,
hidden: true,
disableCancelling: true,
disableRescheduling: true,
allowReschedulingCancelledBookings: true,
redirectUrlOnNoRoutingFormResponse: true,
interfaceLanguage: true,
hosts: {
take: 3,
select: {
user: {
@@ -445,6 +445,9 @@ export const EventAdvancedTab = ({
setInterfaceLanguageVisible(watchedInterfaceLanguage !== null && watchedInterfaceLanguage !== undefined);
}, [watchedInterfaceLanguage]);
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
const [noRoutingFormRedirectUrlVisible, setNoRoutingFormRedirectUrlVisible] = useState(
!!formMethods.getValues("redirectUrlOnNoRoutingFormResponse")
);
const bookingFields: Prisma.JsonObject = {};
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
@@ -949,6 +952,51 @@ export const EventAdvancedTab = ({
</>
)}
/>
<Controller
name="redirectUrlOnNoRoutingFormResponse"
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName={classNames("text-sm", customClassNames?.bookingRedirect?.label)}
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
noRoutingFormRedirectUrlVisible && "rounded-b-none",
customClassNames?.bookingRedirect?.container
)}
childrenClassName={classNames("lg:ml-0", customClassNames?.bookingRedirect?.children)}
descriptionClassName={customClassNames?.bookingRedirect?.description}
title={t("redirect_on_no_routing_form")}
data-testid="redirect-on-no-routing-form"
{...successRedirectUrlLocked}
description={t("redirect_on_no_routing_form_description")}
checked={noRoutingFormRedirectUrlVisible}
onCheckedChange={(e) => {
setNoRoutingFormRedirectUrlVisible(e);
onChange(e ? value : "");
}}>
<div
className={classNames(
"border-subtle rounded-b-lg border border-t-0 p-6",
customClassNames?.bookingRedirect?.redirectUrlInput?.container
)}>
<TextField
className={classNames("w-full", customClassNames?.bookingRedirect?.redirectUrlInput?.input)}
label={t("redirect_on_no_routing_form")}
labelClassName={customClassNames?.bookingRedirect?.redirectUrlInput?.label}
labelSrOnly
disabled={successRedirectUrlLocked.disabled}
placeholder={t("external_redirect_url")}
data-testid="no-routing-form-redirect-url"
required={noRoutingFormRedirectUrlVisible}
type="text"
{...formMethods.register("redirectUrlOnNoRoutingFormResponse")}
/>
</div>
</SettingsToggle>
</>
)}
/>
{!isPlatform && (
<Controller
name="multiplePrivateLinks"
@@ -1435,6 +1435,8 @@
"you_are_being_redirected": "You are being redirected to {{ url }} in $t(second, {\"count\": {{seconds}} }).",
"external_redirect_url": "https://example.com/redirect-to-my-success-page",
"redirect_url_description": "Redirect to a custom URL after a successful booking",
"redirect_on_no_routing_form": "Redirect when accessed without routing form",
"redirect_on_no_routing_form_description": "Redirect to set URL if the event type was not routed to via a routing form",
"duplicate": "Duplicate",
"offer_seats": "Offer seats",
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings. <0>Learn more</0>",
@@ -168,6 +168,19 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
} as const;
}
// Redirect if no routing form response and redirect URL is configured
// Don't redirect if this is a reschedule or seated booking flow
const hasRoutingFormResponse =
context.query["cal.routingFormResponseId"] || context.query["cal.queuedFormResponseId"];
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && "redirectUrlOnNoRoutingFormResponse" in eventData && eventData.redirectUrlOnNoRoutingFormResponse) {
return {
redirect: {
destination: eventData.redirectUrlOnNoRoutingFormResponse,
permanent: false,
},
};
}
const props: Props = {
eventData: {
...eventData,
@@ -254,13 +267,26 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
session?.user?.id
);
if (!eventData) {
return {
notFound: true,
} as const;
}
if (!eventData) {
return {
notFound: true,
} as const;
}
const allowSEOIndexing = org
// Redirect if no routing form response and redirect URL is configured
// Don't redirect if this is a reschedule or seated booking flow
const hasRoutingFormResponse =
context.query["cal.routingFormResponseId"] || context.query["cal.queuedFormResponseId"];
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && "redirectUrlOnNoRoutingFormResponse" in eventData && eventData.redirectUrlOnNoRoutingFormResponse) {
return {
redirect: {
destination: eventData.redirectUrlOnNoRoutingFormResponse,
permanent: false,
},
};
}
const allowSEOIndexing= org
? user?.profile?.organization?.organizationSettings?.allowSEOIndexing
? user?.allowSEOIndexing
: false
@@ -144,6 +144,7 @@ describe("handleChildrenEventTypes", () => {
includeNoShowInRRCalculation,
instantMeetingScheduleId,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -171,6 +172,7 @@ describe("handleChildrenEventTypes", () => {
includeNoShowInRRCalculation,
instantMeetingScheduleId,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
@@ -233,6 +235,7 @@ describe("handleChildrenEventTypes", () => {
includeNoShowInRRCalculation,
instantMeetingScheduleId,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -360,6 +363,7 @@ describe("handleChildrenEventTypes", () => {
instantMeetingScheduleId,
assignRRMembersUsingSegment,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -388,6 +392,7 @@ describe("handleChildrenEventTypes", () => {
instantMeetingScheduleId,
assignRRMembersUsingSegment,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
@@ -452,6 +457,7 @@ describe("handleChildrenEventTypes", () => {
rrSegmentQueryValue,
useEventLevelSelectedCalendars,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -527,6 +533,7 @@ describe("handleChildrenEventTypes", () => {
instantMeetingScheduleId,
assignRRMembersUsingSegment,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -562,6 +569,7 @@ describe("handleChildrenEventTypes", () => {
instantMeetingScheduleId,
assignRRMembersUsingSegment,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
@@ -588,6 +596,7 @@ describe("handleChildrenEventTypes", () => {
assignRRMembersUsingSegment: false,
includeNoShowInRRCalculation: false,
enablePerHostLocations,
redirectUrlOnNoRoutingFormResponse,
...evType,
};
@@ -189,9 +189,11 @@ export const useBookingSuccessRedirect = () => {
query,
booking,
forwardParamsSuccessRedirect,
redirectUrlOnNoRoutingFormResponse,
}: {
successRedirectUrl: EventType["successRedirectUrl"];
forwardParamsSuccessRedirect: EventType["forwardParamsSuccessRedirect"];
redirectUrlOnNoRoutingFormResponse?: EventType["redirectUrlOnNoRoutingFormResponse"];
query: Record<string, string | null | undefined | boolean>;
booking: SuccessRedirectBookingType;
}) => {
@@ -201,6 +203,16 @@ export const useBookingSuccessRedirect = () => {
"cal.rerouting": searchParams.get("cal.rerouting"),
};
// Check if there's no routing form response and redirect URL is configured
const hasRoutingFormResponse =
searchParams.get("cal.routingFormResponseId") || searchParams.get("cal.queuedFormResponseId");
if (!hasRoutingFormResponse && redirectUrlOnNoRoutingFormResponse) {
const url = new URL(redirectUrlOnNoRoutingFormResponse);
navigateInTopWindow(url.toString());
return;
}
if (successRedirectUrl) {
const url = new URL(successRedirectUrl);
// Using parent ensures, Embed iframe would redirect outside of the iframe.
@@ -155,6 +155,7 @@ const commons = {
updatedAt: null,
rrHostSubsetEnabled: false,
enablePerHostLocations: false,
redirectUrlOnNoRoutingFormResponse: null,
};
export const dynamicEvent = {
@@ -129,9 +129,10 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => {
},
},
},
successRedirectUrl: true,
forwardParamsSuccessRedirect: true,
workflows: {
successRedirectUrl: true,
forwardParamsSuccessRedirect: true,
redirectUrlOnNoRoutingFormResponse: true,
workflows: {
include: {
workflow: {
include: {
@@ -160,6 +160,7 @@ export type FormValues = {
externalId: string;
};
successRedirectUrl: string;
redirectUrlOnNoRoutingFormResponse: string;
durationLimits?: IntervalLimit;
bookingLimits?: IntervalLimit;
onlyShowFirstAvailableSlot: boolean;
@@ -622,6 +622,7 @@ export class EventTypeRepository implements IEventTypesRepository {
isRRWeightsEnabled: true,
rescheduleWithSameRoundRobinHost: true,
successRedirectUrl: true,
redirectUrlOnNoRoutingFormResponse: true,
forwardParamsSuccessRedirect: true,
currency: true,
bookingFields: true,
@@ -937,6 +938,7 @@ export class EventTypeRepository implements IEventTypesRepository {
isRRWeightsEnabled: true,
rescheduleWithSameRoundRobinHost: true,
successRedirectUrl: true,
redirectUrlOnNoRoutingFormResponse: true,
forwardParamsSuccessRedirect: true,
currency: true,
bookingFields: true,
+1
View File
@@ -147,6 +147,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
metadata: null,
successRedirectUrl: null,
forwardParamsSuccessRedirect: true,
redirectUrlOnNoRoutingFormResponse: null,
bookingFields: [],
parentId: null,
profileId: null,
@@ -103,6 +103,7 @@ export const useEventTypeForm = ({
hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)),
hostGroups: eventType.hostGroups || [],
successRedirectUrl: eventType.successRedirectUrl || "",
redirectUrlOnNoRoutingFormResponse: eventType.redirectUrlOnNoRoutingFormResponse || "",
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
users: eventType.users,
useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."EventType" ADD COLUMN "redirectUrlOnNoRoutingFormResponse" TEXT;
+2
View File
@@ -235,6 +235,8 @@ model EventType {
/// @zod.import(["import { successRedirectUrl } from '../../zod-utils'"]).custom.use(successRedirectUrl)
successRedirectUrl String?
forwardParamsSuccessRedirect Boolean? @default(true)
/// @zod.import(["import { successRedirectUrl } from '../../zod-utils'"]).custom.use(successRedirectUrl)
redirectUrlOnNoRoutingFormResponse String?
workflows WorkflowsOnEventTypes[]
/// @zod.import(["import { intervalLimitsType } from '../../zod-utils'"]).custom.use(intervalLimitsType)
bookingLimits Json?
@@ -1,4 +1,4 @@
// Cache bust: workflows.activeOn.eventType no longer includes _count.children
// Cache bust: added redirectUrlOnNoRoutingFormResponse to eventType
import getEventTypeById from "@calcom/features/eventtypes/lib/getEventTypeById";
import type { PrismaClient } from "@calcom/prisma";
@@ -204,6 +204,7 @@ export type TUpdateInputSchema = {
metadata?: EventTypeMetadata;
successRedirectUrl?: string | null;
forwardParamsSuccessRedirect?: boolean | null;
redirectUrlOnNoRoutingFormResponse?: string | null;
bookingLimits?: IntervalLimit | null;
durationLimits?: IntervalLimit | null;
isInstantEvent?: boolean;
@@ -401,6 +402,7 @@ const BaseEventTypeUpdateInput: z.ZodType<TUpdateInputSchema> = z
metadata: EventTypeMetaDataSchema.optional(),
successRedirectUrl: z.string().nullable().optional(),
forwardParamsSuccessRedirect: z.boolean().nullable().optional(),
redirectUrlOnNoRoutingFormResponse: z.string().nullable().optional(),
bookingLimits: intervalLimitsType.nullable().optional(),
durationLimits: intervalLimitsType.nullable().optional(),
isInstantEvent: z.boolean().optional(),