import { BookingStatus, WorkflowActions } from "@prisma/client"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; import classNames from "classnames"; import { createEvent } from "ics"; import { 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 { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations"; import { getEventTypeAppData } from "@calcom/app-store/utils"; import { getEventName } from "@calcom/core/event"; import dayjs, { ConfigType } from "@calcom/dayjs"; import { sdkActionManager, useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; import { parseRecurringEvent } from "@calcom/lib"; import CustomBranding from "@calcom/lib/CustomBranding"; import { formatTime } 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, { baseUserSelect } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { Button, EmailInput, Icon } from "@calcom/ui"; import { timeZone } from "@lib/clock"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import CancelBooking from "@components/booking/CancelBooking"; import { HeadSeo } from "@components/seo/head-seo"; 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 parsedSuccessUrl = new URL(document.URL); const parsedExternalUrl = new URL(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; const { t } = useLocale(); const timerRef = useRef(null); useEffect(() => { 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, urlWithSuccessParams]); 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(), }); export default function Success(props: SuccessProps) { const { t } = useLocale(); const router = useRouter(); const { allRemainingBookings, isSuccessBookingPage, cancel: isCancellationMode, changes, } = querySchema.parse(router.query); if ((isCancellationMode || changes) && typeof window !== "undefined") { window.scrollTo(0, document.body.scrollHeight); } const location: ReturnType = Array.isArray(props.bookingInfo.location) ? props.bookingInfo.location[0] || "" : props.bookingInfo.location || ""; 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 name = props.bookingInfo?.user?.name; const email = props.bookingInfo?.user?.email; const status = props.bookingInfo?.status; const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED; const cancellationReason = props.bookingInfo.cancellationReason; 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 attendeeName = typeof name === "string" ? name : "Nameless"; 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", { 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 (location) { optional["location"] = location; } 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.users.find((user) => (user.id = session.user.id))); useTheme(isSuccessBookingPage ? props.profile.theme : "light"); const title = t( `booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}` ); const customInputs = bookingInfo?.customInputs; const locationToDisplay = getSuccessPageLocationMessage(location, t); const hasSMSAttendeeAction = eventType.workflows.find((workflowEventType) => workflowEventType.workflow.steps.find((step) => step.action === WorkflowActions.SMS_ATTENDEE) ) !== undefined; return (
{userIsOwner && !isEmbed && ( )}
{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 } = 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) => (
{dayjs.tz(dateStr, timeZone()).format("MMMM DD, YYYY")}
{formatTime(dateStr, is24h ? 24 : 12, timeZone())} -{" "} {formatTime(dayjs(dateStr).add(duration, "m"), is24h ? 24 : 12, timeZone())}{" "} ({timeZone()})
))} {recurringBookingsSorted.length > 4 && ( setMoreEventsVisible(!moreEventsVisible)}> + {t("plus_more", { count: recurringBookingsSorted.length - 4 })} {eventType.recurringEvent?.count && recurringBookingsSorted.slice(4).map((dateStr: string, idx: number) => (
{dayjs.tz(dateStr, timeZone()).format("MMMM DD, YYYY")}
{formatTime(dateStr, is24h ? 24 : 12, timeZone())} -{" "} {formatTime(dayjs(dateStr).add(duration, "m"), is24h ? 24 : 12, timeZone())}{" "} ({timeZone()})
))}
)} ); } return (
{dayjs.tz(date, timeZone()).format("MMMM DD, YYYY")}
{formatTime(date, is24h ? 24 : 12, timeZone())} -{" "} {formatTime(dayjs(date).add(duration, "m"), is24h ? 24 : 12, timeZone())}{" "} ({timeZone()})
); } const getEventTypesFromDB = async (id: number) => { 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, locations: true, price: true, currency: true, users: { select: { id: true, name: true, username: true, hideBranding: true, plan: true, theme: true, brandColor: true, darkBrandColor: true, email: true, timeZone: true, }, }, team: { select: { slug: true, name: true, hideBranding: true, }, }, workflows: { select: { workflow: { select: { 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, metadata, }; }; const schema = z.object({ uid: z.string(), email: z.string().optional(), eventTypeSlug: z.string().optional(), }); const handleSeatsEventTypeOnBooking = ( eventType: { seatsPerTimeSlot?: number | null; seatsShowAttendees: boolean | null; [x: string | number | symbol]: unknown; }, booking: 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 booking.description; } else { return; } if (!eventType.seatsShowAttendees) { const attendee = booking?.attendees?.find((a) => a.email === email); booking["attendees"] = attendee ? [attendee] : []; } return; }; 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 } = parsedQuery.data; const bookingInfo = 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, cancellationReason: 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 (!bookingInfo) { return { notFound: true, }; } // @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; const eventTypeRaw = !bookingInfo.eventTypeId ? getDefaultEvent(eventTypeSlug || "") : await getEventTypesFromDB(bookingInfo.eventTypeId); if (!eventTypeRaw) { return { notFound: true, }; } if (!eventTypeRaw.users.length && eventTypeRaw.userId) { // TODO we should add `user User` relation on `EventType` so this extra query isn't needed const user = await prisma.user.findUnique({ where: { id: eventTypeRaw.userId, }, select: baseUserSelect, }); if (user) { eventTypeRaw.users.push(user); } } if (!eventTypeRaw.users.length) { return { notFound: true, }; } const eventType = { ...eventTypeRaw, periodStartDate: eventTypeRaw.periodStartDate?.toString() ?? null, periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null, metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata), recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent), }; 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) { 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, }, }); } 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, }, }; }