import { BookingStatus } from "@prisma/client"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; import classNames from "classnames"; import { createEvent } from "ics"; import type { GetServerSidePropsContext } from "next"; import { useSession } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect, useRef, useState } from "react"; import { RRule } from "rrule"; import { z } from "zod"; import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; import type { getEventLocationValue } from "@calcom/app-store/locations"; import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/app-store/locations"; import { getEventTypeAppData } from "@calcom/app-store/utils"; import { getEventName } from "@calcom/core/event"; import type { ConfigType } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { sdkActionManager, useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; import { SystemField, getBookingFieldsWithSystemFields, } from "@calcom/features/bookings/lib/getBookingFields"; import { parseRecurringEvent } from "@calcom/lib"; import CustomBranding from "@calcom/lib/CustomBranding"; import { APP_NAME } from "@calcom/lib/constants"; import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone, } from "@calcom/lib/date-fns"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat"; import { localStorage } from "@calcom/lib/webstorage"; import prisma from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { Button, EmailInput, HeadSeo, Label } from "@calcom/ui"; import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon"; import { timeZone } from "@lib/clock"; import { getBookingWithResponses } from "@lib/getBooking"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; import CancelBooking from "@components/booking/CancelBooking"; import EventReservationSchema from "@components/schemas/EventReservationSchema"; import { ssrInit } from "@server/lib/ssr"; function redirectToExternalUrl(url: string) { window.parent.location.href = url; } /** * Redirects to external URL with query params from current URL. * Query Params and Hash Fragment if present in external URL are kept intact. */ function RedirectionToast({ url }: { url: string }) { const [timeRemaining, setTimeRemaining] = useState(10); const [isToastVisible, setIsToastVisible] = useState(true); const { t } = useLocale(); const timerRef = useRef(null); const router = useRouter(); const { cancel: isCancellationMode } = querySchema.parse(router.query); const urlWithSuccessParamsRef = useRef(); if (isCancellationMode && timerRef.current) { setIsToastVisible(false); } useEffect(() => { if (!isToastVisible && timerRef.current) { window.clearInterval(timerRef.current); } }, [isToastVisible]); useEffect(() => { const parsedExternalUrl = new URL(url); const parsedSuccessUrl = new URL(document.URL); // eslint-disable-next-line @typescript-eslint/ban-ts-comment /* @ts-ignore */ //https://stackoverflow.com/questions/49218765/typescript-and-iterator-type-iterableiteratort-is-not-an-array-type for (const [name, value] of parsedExternalUrl.searchParams.entries()) { parsedSuccessUrl.searchParams.set(name, value); } const urlWithSuccessParams = parsedExternalUrl.origin + parsedExternalUrl.pathname + "?" + parsedSuccessUrl.searchParams.toString() + parsedExternalUrl.hash; urlWithSuccessParamsRef.current = urlWithSuccessParams; timerRef.current = window.setInterval(() => { if (timeRemaining > 0) { setTimeRemaining((timeRemaining) => { return timeRemaining - 1; }); } else { redirectToExternalUrl(urlWithSuccessParams); window.clearInterval(timerRef.current as number); } }, 1000); return () => { window.clearInterval(timerRef.current as number); }; }, [timeRemaining, url]); if (!isToastVisible) { return null; } return ( <>

Redirecting to {url} ... {t("you_are_being_redirected", { url, seconds: timeRemaining })}

); } type SuccessProps = inferSSRProps; const stringToBoolean = z .string() .optional() .transform((val) => val === "true"); const querySchema = z.object({ uid: z.string(), allRemainingBookings: stringToBoolean, cancel: stringToBoolean, changes: stringToBoolean, reschedule: stringToBoolean, isSuccessBookingPage: z.string().optional(), formerTime: z.string().optional(), email: z.string().optional(), }); export default function Success(props: SuccessProps) { const { t } = useLocale(); const router = useRouter(); const { allRemainingBookings, isSuccessBookingPage, cancel: isCancellationMode, changes, formerTime, email, } = querySchema.parse(router.query); if ((isCancellationMode || changes) && typeof window !== "undefined") { window.scrollTo(0, document.body.scrollHeight); } const location = props.bookingInfo.location as ReturnType; const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse( props?.bookingInfo?.metadata || {} )?.videoCallUrl; if (!location) { // Can't use logger.error because it throws error on client. stdout isn't available to it. console.error(`No location found `); } const status = props.bookingInfo?.status; const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED; const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason; const attendeeName = typeof props?.bookingInfo?.attendees?.[0]?.name === "string" ? props?.bookingInfo?.attendees?.[0]?.name : "Nameless"; const [is24h, setIs24h] = useState(isBrowserLocale24h()); const { data: session } = useSession(); const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime)); const { eventType, bookingInfo } = props; const isBackgroundTransparent = useIsBackgroundTransparent(); const isEmbed = useIsEmbed(); const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; const [calculatedDuration, setCalculatedDuration] = useState(undefined); function setIsCancellationMode(value: boolean) { if (value) router.query.cancel = "true"; else delete router.query.cancel; router.replace({ pathname: router.pathname, query: { ...router.query }, }); } const eventNameObject = { attendeeName, eventType: props.eventType.title, eventName: (props.dynamicEventName as string) || props.eventType.eventName, host: props.profile.name || "Nameless", location: location, t, }; const giphyAppData = getEventTypeAppData(eventType, "giphy"); const giphyImage = giphyAppData?.thankYouPage; const eventName = getEventName(eventNameObject, true); const needsConfirmation = eventType.requiresConfirmation && reschedule != true; const isCancelled = status === "CANCELLED" || status === "REJECTED"; const telemetry = useTelemetry(); useEffect(() => { if (top !== window) { //page_view will be collected automatically by _middleware.ts telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/booking")); } }, [telemetry]); useEffect(() => { const users = eventType.users; if (!sdkActionManager) return; // TODO: We should probably make it consistent with Webhook payload. Some data is not available here, as and when requirement comes we can add sdkActionManager.fire("bookingSuccessful", { booking: bookingInfo, eventType, date: date.toString(), duration: calculatedDuration, organizer: { name: users[0].name || "Nameless", email: users[0].email || "Email-less", timeZone: users[0].timeZone, }, confirmed: !needsConfirmation, // TODO: Add payment details }); setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess())); setIs24h(!!getIs24hClockFromLocalStorage()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventType, needsConfirmation]); useEffect(() => { setCalculatedDuration( dayjs(props.bookingInfo.endTime).diff(dayjs(props.bookingInfo.startTime), "minutes") ); }, []); function eventLink(): string { const optional: { location?: string } = {}; if (locationVideoCallUrl) { optional["location"] = locationVideoCallUrl; } const event = createEvent({ start: [ date.toDate().getUTCFullYear(), (date.toDate().getUTCMonth() as number) + 1, date.toDate().getUTCDate(), date.toDate().getUTCHours(), date.toDate().getUTCMinutes(), ], startInputType: "utc", title: eventName, description: props.eventType.description ? props.eventType.description : undefined, /** formatted to required type of description ^ */ duration: { minutes: calculatedDuration, }, ...optional, }); if (event.error) { throw event.error; } return encodeURIComponent(event.value ? event.value : false); } function getTitle(): string { const titleSuffix = props.recurringBookings ? "_recurring" : ""; if (isCancelled) { return ""; } if (needsConfirmation) { if (props.profile.name !== null) { return t("user_needs_to_confirm_or_reject_booking" + titleSuffix, { user: props.profile.name, }); } return t("needs_to_be_confirmed_or_rejected" + titleSuffix); } return t("emailed_you_and_attendees" + titleSuffix); } const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id); useTheme(isSuccessBookingPage ? props.profile.theme : "light"); const title = t( `booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}` ); const locationToDisplay = getSuccessPageLocationMessage( locationVideoCallUrl ? locationVideoCallUrl : location, t, bookingInfo.status ); const providerName = guessEventLocationType(location)?.label; return (
{!isEmbed && ( )} {userIsOwner && !isEmbed && (
{t("back_to_bookings")}
)}
{isSuccessBookingPage && !isCancellationMode && eventType.successRedirectUrl ? ( ) : null}{" "}
); } Success.isThemeSupported = true; type RecurringBookingsProps = { eventType: SuccessProps["eventType"]; recurringBookings: SuccessProps["recurringBookings"]; date: dayjs.Dayjs; duration: number | undefined; is24h: boolean; allRemainingBookings: boolean; isCancelled: boolean; }; export function RecurringBookings({ eventType, recurringBookings, duration, date, allRemainingBookings, is24h, isCancelled, }: RecurringBookingsProps) { const [moreEventsVisible, setMoreEventsVisible] = useState(false); const { t, i18n: { language }, } = useLocale(); const recurringBookingsSorted = recurringBookings ? recurringBookings.sort((a: ConfigType, b: ConfigType) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1)) : null; if (!duration) return null; if (recurringBookingsSorted && allRemainingBookings) { return ( <> {eventType.recurringEvent?.count && ( {getEveryFreqFor({ t, recurringEvent: eventType.recurringEvent, recurringCount: recurringBookings?.length ?? undefined, })} )} {eventType.recurringEvent?.count && recurringBookingsSorted.slice(0, 4).map((dateStr: string, idx: number) => (
{formatToLocalizedDate(dayjs.tz(dateStr, timeZone()), language, "full")}
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h)} -{" "} {formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h)}{" "} ({formatToLocalizedTimezone(dayjs(dateStr), language)})
))} {recurringBookingsSorted.length > 4 && ( setMoreEventsVisible(!moreEventsVisible)}> + {t("plus_more", { count: recurringBookingsSorted.length - 4 })} {eventType.recurringEvent?.count && recurringBookingsSorted.slice(4).map((dateStr: string, idx: number) => (
{formatToLocalizedDate(dayjs.tz(date, timeZone()), language, "full")}
{formatToLocalizedTime(date, language, undefined, !is24h)} -{" "} {formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h)}{" "} ({formatToLocalizedTimezone(dayjs(dateStr), language)})
))}
)} ); } return (
{formatToLocalizedDate(dayjs.tz(date, timeZone()), language, "full")}
{formatToLocalizedTime(date, language, undefined, !is24h)} -{" "} {formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h)}{" "} ({formatToLocalizedTimezone(date, language)})
); } const getEventTypesFromDB = async (id: number) => { const userSelect = { id: true, name: true, username: true, hideBranding: true, theme: true, brandColor: true, darkBrandColor: true, email: true, timeZone: true, }; const eventType = await prisma.eventType.findUnique({ where: { id, }, select: { id: true, title: true, description: true, length: true, eventName: true, recurringEvent: true, requiresConfirmation: true, userId: true, successRedirectUrl: true, customInputs: true, locations: true, price: true, currency: true, bookingFields: true, disableGuests: true, owner: { select: userSelect, }, users: { select: userSelect, }, hosts: { select: { user: { select: userSelect, }, }, }, team: { select: { slug: true, name: true, hideBranding: true, }, }, workflows: { select: { workflow: { select: { id: true, steps: true, }, }, }, }, metadata: true, seatsPerTimeSlot: true, seatsShowAttendees: true, periodStartDate: true, periodEndDate: true, }, }); if (!eventType) { return eventType; } const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); return { isDynamic: false, ...eventType, bookingFields: getBookingFieldsWithSystemFields(eventType), metadata, }; }; const schema = z.object({ uid: z.string(), email: z.string().optional(), eventTypeSlug: z.string().optional(), cancel: stringToBoolean, }); const handleSeatsEventTypeOnBooking = ( eventType: { seatsPerTimeSlot?: number | null; seatsShowAttendees: boolean | null; [x: string | number | symbol]: unknown; }, bookingInfo: Partial< Prisma.BookingGetPayload<{ include: { attendees: { select: { name: true; email: true } } } }> >, email: string ) => { if (eventType?.seatsPerTimeSlot !== null) { // @TODO: right now bookings with seats doesn't save every description that its entered by every user delete bookingInfo.description; } else { return; } if (!eventType.seatsShowAttendees) { const attendee = bookingInfo?.attendees?.find((a) => { return a.email === email; }); bookingInfo["attendees"] = attendee ? [attendee] : []; } return bookingInfo; }; export async function getServerSideProps(context: GetServerSidePropsContext) { const ssr = await ssrInit(context); const parsedQuery = schema.safeParse(context.query); if (!parsedQuery.success) return { notFound: true }; const { uid, email, eventTypeSlug, cancel } = parsedQuery.data; const bookingInfoRaw = await prisma.booking.findFirst({ where: { uid, }, select: { title: true, id: true, uid: true, description: true, customInputs: true, smsReminderNumber: true, recurringEventId: true, startTime: true, endTime: true, location: true, status: true, metadata: true, cancellationReason: true, responses: true, rejectionReason: true, user: { select: { id: true, name: true, email: true, username: true, }, }, attendees: { select: { name: true, email: true, }, }, eventTypeId: true, eventType: { select: { eventName: true, slug: true, }, }, }, }); if (!bookingInfoRaw) { return { notFound: true, }; } const eventTypeRaw = !bookingInfoRaw.eventTypeId ? getDefaultEvent(eventTypeSlug || "") : await getEventTypesFromDB(bookingInfoRaw.eventTypeId); if (!eventTypeRaw) { return { notFound: true, }; } const bookingInfo = getBookingWithResponses(bookingInfoRaw, eventTypeRaw); // @NOTE: had to do this because Server side cant return [Object objects] // probably fixable with json.stringify -> json.parse bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date; bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date; eventTypeRaw.users = !!eventTypeRaw.hosts?.length ? eventTypeRaw.hosts.map((host) => host.user) : eventTypeRaw.users; if (!eventTypeRaw.users.length) { if (!eventTypeRaw.owner) return { notFound: true, }; eventTypeRaw.users.push({ ...eventTypeRaw.owner, }); } const eventType = { ...eventTypeRaw, periodStartDate: eventTypeRaw.periodStartDate?.toString() ?? null, periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null, metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata), recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent), customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs), }; if (eventType.metadata?.disableSuccessPage && eventType.successRedirectUrl && !cancel) { return { redirect: { destination: eventType.successRedirectUrl, permanent: false, }, }; } const profile = { name: eventType.team?.name || eventType.users[0]?.name || null, email: eventType.team ? null : eventType.users[0].email || null, theme: (!eventType.team?.name && eventType.users[0]?.theme) || null, brandColor: eventType.team ? null : eventType.users[0].brandColor || null, darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null, slug: eventType.team?.slug || eventType.users[0]?.username || null, }; if (bookingInfo !== null && email && eventType.seatsPerTimeSlot) { handleSeatsEventTypeOnBooking(eventType, bookingInfo, email); } let recurringBookings = null; if (bookingInfo.recurringEventId) { // We need to get the dates for the bookings to be able to show them in the UI recurringBookings = await prisma.booking.findMany({ where: { recurringEventId: bookingInfo.recurringEventId, }, select: { startTime: true, }, }); } const payment = await prisma.payment.findFirst({ where: { bookingId: bookingInfo.id, }, select: { success: true, refunded: true, }, }); const paymentStatus = { success: payment?.success, refunded: payment?.refunded, }; return { props: { hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding, profile, eventType, recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null, trpcState: ssr.dehydrate(), dynamicEventName: bookingInfo?.eventType?.eventName || "", bookingInfo, paymentStatus: payment ? paymentStatus : null, }, }; }