diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 41c0a54d50..60fea3c386 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -16,6 +16,7 @@ const V2_WHITELIST = [ // Apps contains trailing slash to prevent app overview from being rendered as v2, // since it doesn't exist yet. "/apps/", + "/success", ]; const middleware: NextMiddleware = async (req) => { diff --git a/apps/web/pages/v2/success.tsx b/apps/web/pages/v2/success.tsx new file mode 100644 index 0000000000..5f53903256 --- /dev/null +++ b/apps/web/pages/v2/success.tsx @@ -0,0 +1,880 @@ +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 { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations"; +import { getEventName } from "@calcom/core/event"; +import dayjs 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 { 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 { isBrowserLocale24h } from "@calcom/lib/timeFormat"; +import { localStorage } from "@calcom/lib/webstorage"; +import prisma from "@calcom/prisma"; +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 { isBrandingHidden } from "@lib/isBrandingHidden"; +import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable"; +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, 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 metadata = props.eventType?.metadata as { giphyThankYouPage: string }; + const giphyImage = metadata?.giphyThankYouPage; + + 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(!!localStorage.getItem("timeOption.is24hClock")); + // 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")} + + +
+ )} + + +
+
+ {isSuccessRedirectAvailable(eventType) && 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, + listingStatus, +}: RecurringBookingsProps) { + const [moreEventsVisible, setMoreEventsVisible] = useState(false); + const { t } = useLocale(); + + const recurringBookingsSorted = recurringBookings + ? recurringBookings.sort((a, b) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1)) + : null; + + return recurringBookingsSorted && listingStatus === "recurring" ? ( + <> + {eventType.recurringEvent?.count && ( + + {getEveryFreqFor({ + t, + recurringEvent: eventType.recurringEvent, + recurringCount: recurringBookings?.length ?? undefined, + })} + + )} + {eventType.recurringEvent?.count && + recurringBookingsSorted.slice(0, 4).map((dateStr, idx) => ( +
+ {dayjs(dateStr).format("MMMM DD, YYYY")} +
+ {dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "} + + ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) + +
+ ))} + {recurringBookingsSorted.length > 4 && ( + setMoreEventsVisible(!moreEventsVisible)}> + + {t("plus_more", { count: recurringBookingsSorted.length - 4 })} + + + {eventType.recurringEvent?.count && + recurringBookingsSorted.slice(4).map((dateStr, idx) => ( +
+ {dayjs(dateStr).format("MMMM DD, YYYY")} +
+ {dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "} + + ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) + +
+ ))} +
+
+ )} + + ) : ( + <> + {date.format("MMMM DD, YYYY")} +
+ {date.format("LT")} - {date.add(eventType.length, "m").format("LT")}{" "} + + ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) + + + ); +} + +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, + 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, + }, + }); + + if (!eventType) { + return eventType; + } + + return { + isDynamic: false, + ...eventType, + }; +}; + +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(), + user: 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, +}); + +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, + user: 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: { + id: true, + name: true, + username: true, + hideBranding: true, + plan: true, + theme: true, + brandColor: true, + darkBrandColor: true, + email: true, + timeZone: true, + }, + }); + if (user) { + eventTypeRaw.users.push(user); + } + } + + if (!eventTypeRaw.users.length) { + return { + notFound: true, + }; + } + + const eventType = { + ...eventTypeRaw, + 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, + uid: true, + description: true, + customInputs: true, + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + attendees: { + select: { + name: true, + email: true, + }, + }, + }, + }); + 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 : isBrandingHidden(eventType.users[0]), + profile, + eventType, + recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null, + trpcState: ssr.dehydrate(), + dynamicEventName, + bookingInfo, + }, + }; +}