import { useRouter } from "next/navigation"; import dayjs from "@calcom/dayjs"; import type { PaymentPageProps } from "@calcom/ee/payments/pages/payment"; import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import type { BookingResponse } from "@calcom/features/bookings/types"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { navigateInTopWindow } from "@calcom/lib/navigateInTopWindow"; import type { EventType } from "@calcom/prisma/client"; import { getSafe } from "./getSafe"; export function getNewSearchParams(args: { query: Record; searchParams?: URLSearchParams; filterInternalParams?: boolean; }) { const { query, searchParams, filterInternalParams = false } = args; const newSearchParams = new URLSearchParams(); // Embed-specific params const embedParams = new Set(["embed", "layout", "embedType", "ui.color-scheme"]); // Webapp-specific params const webappParams = new Set(["overlayCalendar"]); // Add non-excluded params from searchParams if provided if (searchParams) { searchParams.forEach((value, key) => { if (shouldExcludeParam(key)) { return; } newSearchParams.append(key, value); }); } // Add params from query, filtering excluded params Object.entries(query).forEach(([key, value]) => { if (value === null || value === undefined) { return; } if (shouldExcludeParam(key)) { return; } newSearchParams.append(key, String(value)); }); function shouldExcludeParam(key: string) { if (filterInternalParams) { return embedParams.has(key) || webappParams.has(key); } return false; } return newSearchParams; } type SuccessRedirectBookingType = Pick< BookingResponse | PaymentPageProps["booking"], "uid" | "title" | "description" | "startTime" | "endTime" | "location" | "attendees" | "user" | "responses" >; type BookingResponseKey = keyof SuccessRedirectBookingType; type ResultType = { [key in BookingResponseKey]?: SuccessRedirectBookingType[key]; } & { hostName?: string[]; attendeeName?: string | null; hostStartTime?: string | null; attendeeStartTime?: string | null; guestEmails?: string[] | null; phone?: string | null; attendeeFirstName?: string | null; attendeeLastName?: string | null; }; export const getBookingRedirectExtraParams = (booking: SuccessRedirectBookingType) => { const redirectQueryParamKeys: BookingResponseKey[] = [ "title", "description", "startTime", "endTime", "location", "attendees", "user", "responses", ]; // Helper function to extract response details (e.g., phone, attendee's first and last name) function extractResponseDetails(booking: SuccessRedirectBookingType, obj: ResultType): ResultType { const result: ResultType = { ...obj }; const phone = getSafe(booking.responses, ["phone"]); const firstName = getSafe(booking.responses, ["name", "firstName"]); const lastName = getSafe(booking.responses, ["name", "lastName"]); const name = getSafe(booking.responses, ["name"]); if (phone) result.phone = phone; if (firstName) result.attendeeFirstName = firstName; if (lastName) result.attendeeLastName = lastName; else if (name && typeof name === "string") result.attendeeName = name; // Fallback if `name` is a string instead of an object return result; } // Helper function to extract user details (e.g., host name and time zone) function extractUserDetails(booking: SuccessRedirectBookingType, obj: ResultType): ResultType { if (booking.user?.name) { const hostStartTime = dayjs(booking.startTime).tz(booking.user.timeZone).format(); return { ...obj, hostName: [...(obj.hostName || []), booking.user.name], hostStartTime, }; } return obj; } // Helper function to extract attendee and guest details function extractAttendeesAndGuests(booking: SuccessRedirectBookingType, obj: ResultType): ResultType { if (!Array.isArray(booking.attendees) || booking.attendees.length === 0) return obj; const attendeeName = booking.attendees[0]?.name || null; const attendeeTimeZone = booking.attendees[0]?.timeZone || "UTC"; const attendeeStartTime = dayjs(booking.startTime).tz(attendeeTimeZone).format(); const { hostNames, guestEmails } = booking.attendees.slice(1).reduce( (acc, attendee) => { if (attendee.name) { acc.hostNames.push(attendee.name); } else if (attendee.email) { acc.guestEmails.push(attendee.email); } return acc; }, { hostNames: [], guestEmails: [] } as { hostNames: string[]; guestEmails: string[] } ); return { ...obj, attendeeName, attendeeStartTime, hostName: [...(obj.hostName || []), ...hostNames], guestEmails: guestEmails.length > 0 ? guestEmails : undefined, }; } const bookingParams = (Object.keys(booking) as BookingResponseKey[]) .filter((key) => redirectQueryParamKeys.includes(key)) .reduce( (obj, key) => { if (key === "responses") return extractResponseDetails(booking, obj); if (key === "user") return extractUserDetails(booking, obj); if (key === "attendees") return extractAttendeesAndGuests(booking, obj); return { ...obj, [key]: booking[key] }; }, { uid: booking.uid } ); const queryCompatibleParams: Record = { ...Object.fromEntries( Object.entries(bookingParams).map(([key, value]) => { if (Array.isArray(value)) { return [key, value.join(", ")]; } if (typeof value === "object" && value !== null) { // Skip complex objects (user, attendees) as we are extracting only needed fields return [key, undefined]; } return [key, value]; }) ), hostName: bookingParams.hostName?.join(", "), attendeeName: bookingParams.attendeeName || undefined, hostStartTime: bookingParams.hostStartTime || undefined, attendeeStartTime: bookingParams.attendeeStartTime || undefined, }; return queryCompatibleParams; }; export const useBookingSuccessRedirect = () => { const router = useRouter(); const searchParams = useCompatSearchParams(); const isEmbed = useIsEmbed(); const bookingSuccessRedirect = ({ successRedirectUrl, query, booking, forwardParamsSuccessRedirect, }: { successRedirectUrl: EventType["successRedirectUrl"]; forwardParamsSuccessRedirect: EventType["forwardParamsSuccessRedirect"]; query: Record; booking: SuccessRedirectBookingType; }) => { // Ensures that the param is added both to external redirect url and booking success page URL query = { ...query, "cal.rerouting": searchParams.get("cal.rerouting"), }; if (successRedirectUrl) { const url = new URL(successRedirectUrl); // Using parent ensures, Embed iframe would redirect outside of the iframe. if (!forwardParamsSuccessRedirect) { navigateInTopWindow(url.toString()); return; } const bookingExtraParams = getBookingRedirectExtraParams(booking); // Filter internal Cal.com params when redirecting to external URLs. // - It prevents leaking internal state. // - Certain websites might break due to the presence of certain params e.g. Wordpress has different meaning for `embed` param and an embed param passed by Cal.com breaks a wordpress webpage const newSearchParams = getNewSearchParams({ query: { ...query, ...bookingExtraParams, isEmbed, }, searchParams: new URLSearchParams(searchParams.toString()), filterInternalParams: true, }); newSearchParams.forEach((value, key) => { url.searchParams.append(key, value); }); navigateInTopWindow(url.toString()); return; } // TODO: Abstract it out and reuse at other places where we navigate within the embed. Though this is needed only in case of hard navigation happening but we aren't sure where hard navigation happens and where a soft navigation // This is specially true after App Router it seems const headersRelatedSearchParams = searchParams ? { "flag.coep": searchParams.get("flag.coep") ?? "false", } : undefined; // We don't want to forward all search params, as they could possibly break the booking page. const newSearchParams = getNewSearchParams({ query, searchParams: new URLSearchParams(headersRelatedSearchParams), }); return router.push(`/booking/${booking.uid}${isEmbed ? "/embed" : ""}?${newSearchParams.toString()}`); }; return bookingSuccessRedirect; };