Skip to content

Commit c0f6a1b

Browse files
feat: add redirect option for non-routed visits to event types (calcom#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>
1 parent 7062887 commit c0f6a1b

17 files changed

Lines changed: 159 additions & 36 deletions

File tree

apps/web/lib/org/[orgSlug]/instant-meeting/team/[slug]/[type]/getServerSideProps.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
6262
session?.user?.id
6363
);
6464

65-
if (!eventData) {
66-
return {
67-
notFound: true,
68-
} as const;
69-
}
65+
if (!eventData) {
66+
return {
67+
notFound: true,
68+
} as const;
69+
}
7070

71-
return {
72-
props: {
71+
return {
72+
props: {
7373
eventData,
7474
entity: eventData.entity,
7575
eventTypeId: eventData.id,

apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ function hasApiV2RouteInEnv() {
3030
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
3131
const { req, params, query } = context;
3232
const session = await getServerSession({ req });
33-
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
34-
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
33+
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
34+
const { rescheduleUid, bookingUid, isInstantMeeting: queryIsInstantMeeting } = query;
3535
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
3636
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
3737

@@ -55,11 +55,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
5555

5656
const eventData = team.eventTypes[0];
5757

58-
if (eventData.schedulingType === SchedulingType.MANAGED) {
59-
return { notFound: true } as const;
60-
}
58+
if (eventData.schedulingType === SchedulingType.MANAGED) {
59+
return { notFound: true } as const;
60+
}
61+
62+
// Redirect if no routing form response and redirect URL is configured
63+
// Don't redirect if this is a reschedule flow or seated booking flow
64+
const hasRoutingFormResponse = query["cal.routingFormResponseId"] || query["cal.queuedFormResponseId"];
65+
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && eventData.redirectUrlOnNoRoutingFormResponse) {
66+
return {
67+
redirect: {
68+
destination: eventData.redirectUrlOnNoRoutingFormResponse,
69+
permanent: false,
70+
},
71+
};
72+
}
6173

62-
if (rescheduleUid && eventData.disableRescheduling) {
74+
if (rescheduleUid && eventData.disableRescheduling) {
6375
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
6476
}
6577

@@ -239,19 +251,20 @@ const getTeamWithEventsData = async (
239251
where: {
240252
slug: meetingSlug,
241253
},
242-
select: {
243-
id: true,
244-
title: true,
245-
isInstantEvent: true,
246-
schedulingType: true,
247-
metadata: true,
248-
length: true,
249-
hidden: true,
250-
disableCancelling: true,
251-
disableRescheduling: true,
252-
allowReschedulingCancelledBookings: true,
253-
interfaceLanguage: true,
254-
hosts: {
254+
select: {
255+
id: true,
256+
title: true,
257+
isInstantEvent: true,
258+
schedulingType: true,
259+
metadata: true,
260+
length: true,
261+
hidden: true,
262+
disableCancelling: true,
263+
disableRescheduling: true,
264+
allowReschedulingCancelledBookings: true,
265+
redirectUrlOnNoRoutingFormResponse: true,
266+
interfaceLanguage: true,
267+
hosts: {
255268
take: 3,
256269
select: {
257270
user: {

apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,9 @@ export const EventAdvancedTab = ({
445445
setInterfaceLanguageVisible(watchedInterfaceLanguage !== null && watchedInterfaceLanguage !== undefined);
446446
}, [watchedInterfaceLanguage]);
447447
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
448+
const [noRoutingFormRedirectUrlVisible, setNoRoutingFormRedirectUrlVisible] = useState(
449+
!!formMethods.getValues("redirectUrlOnNoRoutingFormResponse")
450+
);
448451

449452
const bookingFields: Prisma.JsonObject = {};
450453
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
@@ -949,6 +952,51 @@ export const EventAdvancedTab = ({
949952
</>
950953
)}
951954
/>
955+
<Controller
956+
name="redirectUrlOnNoRoutingFormResponse"
957+
render={({ field: { value, onChange } }) => (
958+
<>
959+
<SettingsToggle
960+
labelClassName={classNames("text-sm", customClassNames?.bookingRedirect?.label)}
961+
toggleSwitchAtTheEnd={true}
962+
switchContainerClassName={classNames(
963+
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
964+
noRoutingFormRedirectUrlVisible && "rounded-b-none",
965+
customClassNames?.bookingRedirect?.container
966+
)}
967+
childrenClassName={classNames("lg:ml-0", customClassNames?.bookingRedirect?.children)}
968+
descriptionClassName={customClassNames?.bookingRedirect?.description}
969+
title={t("redirect_on_no_routing_form")}
970+
data-testid="redirect-on-no-routing-form"
971+
{...successRedirectUrlLocked}
972+
description={t("redirect_on_no_routing_form_description")}
973+
checked={noRoutingFormRedirectUrlVisible}
974+
onCheckedChange={(e) => {
975+
setNoRoutingFormRedirectUrlVisible(e);
976+
onChange(e ? value : "");
977+
}}>
978+
<div
979+
className={classNames(
980+
"border-subtle rounded-b-lg border border-t-0 p-6",
981+
customClassNames?.bookingRedirect?.redirectUrlInput?.container
982+
)}>
983+
<TextField
984+
className={classNames("w-full", customClassNames?.bookingRedirect?.redirectUrlInput?.input)}
985+
label={t("redirect_on_no_routing_form")}
986+
labelClassName={customClassNames?.bookingRedirect?.redirectUrlInput?.label}
987+
labelSrOnly
988+
disabled={successRedirectUrlLocked.disabled}
989+
placeholder={t("external_redirect_url")}
990+
data-testid="no-routing-form-redirect-url"
991+
required={noRoutingFormRedirectUrlVisible}
992+
type="text"
993+
{...formMethods.register("redirectUrlOnNoRoutingFormResponse")}
994+
/>
995+
</div>
996+
</SettingsToggle>
997+
</>
998+
)}
999+
/>
9521000
{!isPlatform && (
9531001
<Controller
9541002
name="multiplePrivateLinks"

apps/web/public/static/locales/en/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,8 @@
14351435
"you_are_being_redirected": "You are being redirected to {{ url }} in $t(second, {\"count\": {{seconds}} }).",
14361436
"external_redirect_url": "https://example.com/redirect-to-my-success-page",
14371437
"redirect_url_description": "Redirect to a custom URL after a successful booking",
1438+
"redirect_on_no_routing_form": "Redirect when accessed without routing form",
1439+
"redirect_on_no_routing_form_description": "Redirect to set URL if the event type was not routed to via a routing form",
14381440
"duplicate": "Duplicate",
14391441
"offer_seats": "Offer seats",
14401442
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings. <0>Learn more</0>",

apps/web/server/lib/[user]/[type]/getServerSideProps.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
168168
} as const;
169169
}
170170

171+
// Redirect if no routing form response and redirect URL is configured
172+
// Don't redirect if this is a reschedule or seated booking flow
173+
const hasRoutingFormResponse =
174+
context.query["cal.routingFormResponseId"] || context.query["cal.queuedFormResponseId"];
175+
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && "redirectUrlOnNoRoutingFormResponse" in eventData && eventData.redirectUrlOnNoRoutingFormResponse) {
176+
return {
177+
redirect: {
178+
destination: eventData.redirectUrlOnNoRoutingFormResponse,
179+
permanent: false,
180+
},
181+
};
182+
}
183+
171184
const props: Props = {
172185
eventData: {
173186
...eventData,
@@ -254,13 +267,26 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
254267
session?.user?.id
255268
);
256269

257-
if (!eventData) {
258-
return {
259-
notFound: true,
260-
} as const;
261-
}
270+
if (!eventData) {
271+
return {
272+
notFound: true,
273+
} as const;
274+
}
275+
276+
// Redirect if no routing form response and redirect URL is configured
277+
// Don't redirect if this is a reschedule or seated booking flow
278+
const hasRoutingFormResponse =
279+
context.query["cal.routingFormResponseId"] || context.query["cal.queuedFormResponseId"];
280+
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && "redirectUrlOnNoRoutingFormResponse" in eventData && eventData.redirectUrlOnNoRoutingFormResponse) {
281+
return {
282+
redirect: {
283+
destination: eventData.redirectUrlOnNoRoutingFormResponse,
284+
permanent: false,
285+
},
286+
};
287+
}
262288

263-
const allowSEOIndexing = org
289+
const allowSEOIndexing= org
264290
? user?.profile?.organization?.organizationSettings?.allowSEOIndexing
265291
? user?.allowSEOIndexing
266292
: false

apps/web/test/lib/handleChildrenEventTypes.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ describe("handleChildrenEventTypes", () => {
144144
includeNoShowInRRCalculation,
145145
instantMeetingScheduleId,
146146
enablePerHostLocations,
147+
redirectUrlOnNoRoutingFormResponse,
147148
...evType
148149
} = mockFindFirstEventType({
149150
id: 123,
@@ -171,6 +172,7 @@ describe("handleChildrenEventTypes", () => {
171172
includeNoShowInRRCalculation,
172173
instantMeetingScheduleId,
173174
enablePerHostLocations,
175+
redirectUrlOnNoRoutingFormResponse,
174176
};
175177
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
176178

@@ -233,6 +235,7 @@ describe("handleChildrenEventTypes", () => {
233235
includeNoShowInRRCalculation,
234236
instantMeetingScheduleId,
235237
enablePerHostLocations,
238+
redirectUrlOnNoRoutingFormResponse,
236239
...evType
237240
} = mockFindFirstEventType({
238241
metadata: { managedEventConfig: {} },
@@ -360,6 +363,7 @@ describe("handleChildrenEventTypes", () => {
360363
instantMeetingScheduleId,
361364
assignRRMembersUsingSegment,
362365
enablePerHostLocations,
366+
redirectUrlOnNoRoutingFormResponse,
363367
...evType
364368
} = mockFindFirstEventType({
365369
id: 123,
@@ -388,6 +392,7 @@ describe("handleChildrenEventTypes", () => {
388392
instantMeetingScheduleId,
389393
assignRRMembersUsingSegment,
390394
enablePerHostLocations,
395+
redirectUrlOnNoRoutingFormResponse,
391396
};
392397
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
393398

@@ -452,6 +457,7 @@ describe("handleChildrenEventTypes", () => {
452457
rrSegmentQueryValue,
453458
useEventLevelSelectedCalendars,
454459
enablePerHostLocations,
460+
redirectUrlOnNoRoutingFormResponse,
455461
...evType
456462
} = mockFindFirstEventType({
457463
metadata: { managedEventConfig: {} },
@@ -527,6 +533,7 @@ describe("handleChildrenEventTypes", () => {
527533
instantMeetingScheduleId,
528534
assignRRMembersUsingSegment,
529535
enablePerHostLocations,
536+
redirectUrlOnNoRoutingFormResponse,
530537
...evType
531538
} = mockFindFirstEventType({
532539
metadata: { managedEventConfig: {} },
@@ -562,6 +569,7 @@ describe("handleChildrenEventTypes", () => {
562569
instantMeetingScheduleId,
563570
assignRRMembersUsingSegment,
564571
enablePerHostLocations,
572+
redirectUrlOnNoRoutingFormResponse,
565573
};
566574
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
567575

@@ -588,6 +596,7 @@ describe("handleChildrenEventTypes", () => {
588596
assignRRMembersUsingSegment: false,
589597
includeNoShowInRRCalculation: false,
590598
enablePerHostLocations,
599+
redirectUrlOnNoRoutingFormResponse,
591600
...evType,
592601
};
593602

packages/features/bookings/lib/bookingSuccessRedirect.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,11 @@ export const useBookingSuccessRedirect = () => {
189189
query,
190190
booking,
191191
forwardParamsSuccessRedirect,
192+
redirectUrlOnNoRoutingFormResponse,
192193
}: {
193194
successRedirectUrl: EventType["successRedirectUrl"];
194195
forwardParamsSuccessRedirect: EventType["forwardParamsSuccessRedirect"];
196+
redirectUrlOnNoRoutingFormResponse?: EventType["redirectUrlOnNoRoutingFormResponse"];
195197
query: Record<string, string | null | undefined | boolean>;
196198
booking: SuccessRedirectBookingType;
197199
}) => {
@@ -201,6 +203,16 @@ export const useBookingSuccessRedirect = () => {
201203
"cal.rerouting": searchParams.get("cal.rerouting"),
202204
};
203205

206+
// Check if there's no routing form response and redirect URL is configured
207+
const hasRoutingFormResponse =
208+
searchParams.get("cal.routingFormResponseId") || searchParams.get("cal.queuedFormResponseId");
209+
210+
if (!hasRoutingFormResponse && redirectUrlOnNoRoutingFormResponse) {
211+
const url = new URL(redirectUrlOnNoRoutingFormResponse);
212+
navigateInTopWindow(url.toString());
213+
return;
214+
}
215+
204216
if (successRedirectUrl) {
205217
const url = new URL(successRedirectUrl);
206218
// Using parent ensures, Embed iframe would redirect outside of the iframe.

packages/features/eventtypes/lib/defaultEvents.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ const commons = {
155155
updatedAt: null,
156156
rrHostSubsetEnabled: false,
157157
enablePerHostLocations: false,
158+
redirectUrlOnNoRoutingFormResponse: null,
158159
};
159160

160161
export const dynamicEvent = {

packages/features/eventtypes/lib/getPublicEvent.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,10 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => {
129129
},
130130
},
131131
},
132-
successRedirectUrl: true,
133-
forwardParamsSuccessRedirect: true,
134-
workflows: {
132+
successRedirectUrl: true,
133+
forwardParamsSuccessRedirect: true,
134+
redirectUrlOnNoRoutingFormResponse: true,
135+
workflows: {
135136
include: {
136137
workflow: {
137138
include: {

packages/features/eventtypes/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export type FormValues = {
160160
externalId: string;
161161
};
162162
successRedirectUrl: string;
163+
redirectUrlOnNoRoutingFormResponse: string;
163164
durationLimits?: IntervalLimit;
164165
bookingLimits?: IntervalLimit;
165166
onlyShowFirstAvailableSlot: boolean;

0 commit comments

Comments
 (0)