import { Prisma } 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 from "@calcom/dayjs"; import { 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 { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import Button from "@calcom/ui/Button"; import { Icon } from "@calcom/ui/Icon"; import { EmailInput } from "@calcom/ui/form/fields"; import { asStringOrThrow } from "@lib/asStringOrNull"; 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; export default function Success(props: SuccessProps) { const { t } = useLocale(); const router = useRouter(); const { location: _location, name, email, reschedule, listingStatus, status, isSuccessBookingPage, } = router.query; const location: ReturnType = Array.isArray(_location) ? _location[0] || "" : _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 [is24h, setIs24h] = useState(isBrowserLocale24h()); const { data: session } = useSession(); const [date, setDate] = useState(dayjs.utc(asStringOrThrow(router.query.date))); const { eventType, bookingInfo } = props; const isBackgroundTransparent = useIsBackgroundTransparent(); const isEmbed = useIsEmbed(); const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; const [isCancellationMode, setIsCancellationMode] = useState(false); 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("/success")); } }, [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: eventType.length, 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]); 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: props.eventType.length }, ...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 t("emailed_information_about_cancelled_event"); } 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); return (
{userIsOwner && !isEmbed && (
{t("back_to_bookings")}
)}
{eventType.successRedirectUrl ? : null}{" "}
); } Success.isThemeSupported = true; type RecurringBookingsProps = { eventType: SuccessProps["eventType"]; recurringBookings: SuccessProps["recurringBookings"]; date: dayjs.Dayjs; is24h: boolean; listingStatus: string; }; export function RecurringBookings({ eventType, recurringBookings, date, is24h, listingStatus, }: 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 (recurringBookingsSorted && listingStatus === "recurring") { 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(eventType.length, "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(eventType.length, "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(eventType.length, "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, }, }, metadata: true, seatsShowAttendees: true, }, }); if (!eventType) { return eventType; } const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); return { isDynamic: false, ...eventType, metadata, }; }; const strToNumber = z.string().transform((val, ctx) => { const parsed = parseInt(val); if (isNaN(parsed)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Not a number" }); return parsed; }); const schema = z.object({ type: strToNumber, date: z.string().optional(), username: z.string().optional(), reschedule: z.string().optional(), name: z.string().optional(), email: z.string().optional(), recur: z.string().optional(), location: z.string().optional(), eventSlug: z.string().default("15min"), eventName: z.string().default(""), bookingId: strToNumber, }); const handleSeatsEventTypeOnBooking = ( eventType: { seatsPerTimeSlot?: boolean | 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; } 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 { type: eventTypeId, recur: recurringEventIdQuery, eventSlug: eventTypeSlug, eventName: dynamicEventName, bookingId, username, name, email, } = parsedQuery.data; const eventTypeRaw = !eventTypeId ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(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, 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, }; const where: Prisma.BookingWhereInput = { id: bookingId, attendees: { some: { email, name } }, }; // Dynamic Event uses EventType from @calcom/lib/defaultEvents(a fake EventType) which doesn't have a real user/team/eventTypeId // So, you can't look them up in DB. if (!eventType.isDynamic) { // A Team Event doesn't have a correct user query param as of now. It is equal to team/{eventSlug} which is not a user, so you can't look it up in DB. if (!eventType.team) { // username being equal to profile.slug isn't applicable for Team or Dynamic Events. where.user = { username }; } where.eventTypeId = eventType.id; } else { // username being equal to eventSlug for Dynamic Event Booking, it can't be used for user lookup. So, just use eventTypeId which would always be null for Dynamic Event Bookings where.eventTypeId = null; } const bookingInfo = await prisma.booking.findFirst({ where, select: { title: true, id: true, uid: true, description: true, customInputs: true, smsReminderNumber: true, user: { select: { id: true, name: true, email: true, }, }, attendees: { select: { name: true, email: true, }, }, }, }); if (bookingInfo !== null && email) { handleSeatsEventTypeOnBooking(eventType, bookingInfo, email); } let recurringBookings = null; if (recurringEventIdQuery) { // 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: recurringEventIdQuery, }, 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, }, }; }