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 { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, 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 { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { Price } from "@calcom/features/bookings/components/event-meta/Price"; import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField"; import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { parseRecurringEvent } from "@calcom/lib"; import { APP_NAME } from "@calcom/lib/constants"; import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone, } from "@calcom/lib/date-fns"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import useGetBrandingColours from "@calcom/lib/getBrandColours"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import useTheme from "@calcom/lib/hooks/useTheme"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; 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 { BookingStatus } from "@calcom/prisma/enums"; import { bookingMetadataSchema, customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { Alert, Badge, Button, EmailInput, HeadSeo, useCalcomTheme } from "@calcom/ui"; import { AlertCircle, Calendar, Check, ChevronLeft, ExternalLink, X } from "@calcom/ui/components/icon"; import { timeZone } from "@lib/clock"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; import PageWrapper from "@components/PageWrapper"; import CancelBooking from "@components/booking/CancelBooking"; import EventReservationSchema from "@components/schemas/EventReservationSchema"; import { ssrInit } from "@server/lib/ssr"; const useBrandColors = ({ brandColor, darkBrandColor, }: { brandColor?: string | null; darkBrandColor?: string | null; }) => { const brandTheme = useGetBrandingColours({ lightVal: brandColor, darkVal: darkBrandColor, }); useCalcomTheme(brandTheme); }; type SuccessProps = inferSSRProps; const stringToBoolean = z .string() .optional() .transform((val) => val === "true"); const querySchema = z.object({ uid: z.string(), email: z.string().optional(), eventTypeSlug: z.string().optional(), cancel: stringToBoolean, allRemainingBookings: stringToBoolean, changes: stringToBoolean, reschedule: stringToBoolean, isSuccessBookingPage: stringToBoolean, formerTime: z.string().optional(), seatReferenceUid: z.string().optional(), }); export default function Success(props: SuccessProps) { const { t } = useLocale(); const router = useRouter(); const routerQuery = useRouterQuery(); const pathname = usePathname(); const searchParams = useSearchParams(); const { allRemainingBookings, isSuccessBookingPage, cancel: isCancellationMode, formerTime, email, seatReferenceUid, } = querySchema.parse(routerQuery); const attendeeTimeZone = props?.bookingInfo?.attendees.find( (attendee) => attendee.email === email )?.timeZone; const tz = props.tz ? props.tz : isSuccessBookingPage && attendeeTimeZone ? attendeeTimeZone : timeZone(); const location = props.bookingInfo.location as ReturnType; const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse( props?.bookingInfo?.metadata || {} )?.videoCallUrl; 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 attendees = props?.bookingInfo?.attendees; const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com")); const [is24h, setIs24h] = useState( props?.userTimeFormat ? props.userTimeFormat === 24 : 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) { const _searchParams = new URLSearchParams(searchParams); if (value) { _searchParams.set("cancel", "true"); } else { if (_searchParams.get("cancel")) { _searchParams.delete("cancel"); } } router.replace(`${pathname}?${_searchParams.toString()}`); } let evtName = props.eventType.eventName; if (eventType.isDynamic && bookingInfo.responses?.title) { evtName = bookingInfo.responses.title as string; } const eventNameObject = { attendeeName, eventType: props.eventType.title, eventName: evtName, host: props.profile.name || "Nameless", location: location, bookingFields: bookingInfo.responses, t, }; const giphyAppData = getEventTypeAppData(eventType, "giphy"); const giphyImage = giphyAppData?.thankYouPage; const eventName = getEventName(eventNameObject, true); // Confirmation can be needed in two cases as of now // - Event Type has require confirmation option enabled always // - EventType has conditionally enabled confirmation option based on how far the booking is scheduled. // - It's a paid event and payment is pending. const needsConfirmation = bookingInfo.status === BookingStatus.PENDING && eventType.requiresConfirmation; const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id); const isLoggedIn = session?.user; const isCancelled = status === "CANCELLED" || status === "REJECTED" || (!!seatReferenceUid && !bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid)); // 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() || "Europe/London") ); setIs24h(props?.userTimeFormat ? props.userTimeFormat === 24 : !!getIs24hClockFromLocalStorage()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventType, needsConfirmation]); useEffect(() => { setCalculatedDuration( dayjs(props.bookingInfo.endTime).diff(dayjs(props.bookingInfo.startTime), "minutes") ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); 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}`); } // This is a weird case where the same route can be opened in booking flow as a success page or as a booking detail page from the app // As Booking Page it has to support configured theme, but as booking detail page it should not do any change. Let Shell.tsx handle it. useTheme(isSuccessBookingPage ? props.profile.theme : "system"); useBrandColors({ brandColor: props.profile.brandColor, darkBrandColor: props.profile.darkBrandColor, }); 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 && ( )} {isLoggedIn && !isEmbed && (
{t("back_to_bookings")}
)}
} CustomIcon={AlertCircle} customIconColor="text-attention dark:text-orange-200" /> )}
); } Success.isBookingPage = true; Success.PageWrapper = PageWrapper; type RecurringBookingsProps = { eventType: SuccessProps["eventType"]; recurringBookings: SuccessProps["recurringBookings"]; date: dayjs.Dayjs; duration: number | undefined; is24h: boolean; allRemainingBookings: boolean; isCancelled: boolean; tz: string; }; export function RecurringBookings({ eventType, recurringBookings, duration, date, allRemainingBookings, is24h, isCancelled, tz, }: 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, tz), language, "full", tz)}
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "} {formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h, tz)}{" "} ({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
))} {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, tz), language, "full", tz)}
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "} {formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "} ({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
))}
)} ); } return (
{formatToLocalizedDate(date, language, "full", tz)}
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "} {formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "} ({formatToLocalizedTimezone(date, language, tz)})
); } 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, timeZone: 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, seatsShowAvailabilityCount: true, periodStartDate: true, periodEndDate: true, }, }); if (!eventType) { return eventType; } const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); return { isDynamic: false, ...eventType, bookingFields: getBookingFieldsWithSystemFields(eventType), metadata, }; }; const handleSeatsEventTypeOnBooking = async ( eventType: { seatsPerTimeSlot?: number | null; seatsShowAttendees: boolean | null; seatsShowAvailabilityCount: boolean | null; [x: string | number | symbol]: unknown; }, bookingInfo: Partial< Prisma.BookingGetPayload<{ include: { attendees: { select: { name: true; email: true } }; seatsReferences: { select: { referenceUid: true } }; user: { select: { id: true; name: true; email: true; username: true; timeZone: true; }; }; }; }> >, seatReferenceUid?: string, userId?: number ) => { 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; } // @TODO: If handling teams, we need to do more check ups for this. if (bookingInfo?.user?.id === userId) { return; } if (!eventType.seatsShowAttendees) { const seatAttendee = await prisma.bookingSeat.findFirst({ where: { referenceUid: seatReferenceUid, }, include: { attendee: { select: { name: true, email: true, }, }, }, }); if (seatAttendee) { const attendee = bookingInfo?.attendees?.find((a) => { return a.email === seatAttendee.attendee?.email; }); bookingInfo["attendees"] = attendee ? [attendee] : []; } else { bookingInfo["attendees"] = []; } } return bookingInfo; }; export async function getServerSideProps(context: GetServerSidePropsContext) { const ssr = await ssrInit(context); const session = await getServerSession(context); let tz: string | null = null; let userTimeFormat: number | null = null; if (session) { const user = await ssr.viewer.me.fetch(); tz = user.timeZone; userTimeFormat = user.timeFormat; } const parsedQuery = querySchema.safeParse(context.query); if (!parsedQuery.success) return { notFound: true }; const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data; const bookingInfoRaw = await prisma.booking.findFirst({ where: { uid: await maybeGetBookingUidFromSeat(prisma, 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, timeZone: true, }, }, attendees: { select: { name: true, email: true, timeZone: true, }, }, eventTypeId: true, eventType: { select: { eventName: true, slug: true, timeZone: true, }, }, seatsReferences: { select: { referenceUid: 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); // @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), }; 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 && eventType.seatsPerTimeSlot) { await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, session?.user.id); } const payment = await prisma.payment.findFirst({ where: { bookingId: bookingInfo.id, }, select: { success: true, refunded: true, currency: true, amount: true, paymentOption: true, }, }); return { props: { themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username, hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding, profile, eventType, recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId), trpcState: ssr.dehydrate(), dynamicEventName: bookingInfo?.eventType?.eventName || "", bookingInfo, paymentStatus: payment, ...(tz && { tz }), userTimeFormat, }, }; } async function getRecurringBookings(recurringEventId: string | null) { if (!recurringEventId) return null; const recurringBookings = await prisma.booking.findMany({ where: { recurringEventId, }, select: { startTime: true, }, }); return recurringBookings.map((obj) => obj.startTime.toString()); }