import { CalendarIcon, ClockIcon, CreditCardIcon, ExclamationCircleIcon, ExclamationIcon, InformationCircleIcon, ClipboardCheckIcon, RefreshIcon, } from "@heroicons/react/solid"; import { zodResolver } from "@hookform/resolvers/zod"; import { EventTypeCustomInputType } from "@prisma/client"; import { useContracts } from "contexts/contractsContext"; import dayjs from "dayjs"; import { useSession } from "next-auth/react"; import dynamic from "next/dynamic"; import Head from "next/head"; import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; import { Controller, useForm, useWatch } from "react-hook-form"; import { FormattedNumber, IntlProvider } from "react-intl"; import { ReactMultiEmail } from "react-multi-email"; import { useMutation } from "react-query"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; import { useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { createPaymentLink } from "@calcom/stripe/client"; import { Button } from "@calcom/ui/Button"; import { Tooltip } from "@calcom/ui/Tooltip"; import { EmailInput, Form } from "@calcom/ui/form/fields"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; import { ensureArray } from "@lib/ensureArray"; import useTheme from "@lib/hooks/useTheme"; import { LocationObject, LocationType } from "@lib/location"; import createBooking from "@lib/mutations/bookings/create-booking"; import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking"; import { parseDate, parseRecurringDates } from "@lib/parseDate"; import slugify from "@lib/slugify"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import CustomBranding from "@components/CustomBranding"; import AvatarGroup from "@components/ui/AvatarGroup"; import type PhoneInputType from "@components/ui/form/PhoneInput"; import { BookPageProps } from "../../../pages/[user]/book"; import { HashLinkPageProps } from "../../../pages/d/[link]/book"; import { TeamBookingPageProps } from "../../../pages/team/[slug]/book"; declare global { // eslint-disable-next-line no-var var web3: { currentProvider: { selectedAddress: string; }; }; } /** These are like 40kb that not every user needs */ const PhoneInput = dynamic( () => import("@components/ui/form/PhoneInput") ) as unknown as typeof PhoneInputType; type BookingPageProps = (BookPageProps | TeamBookingPageProps | HashLinkPageProps) & { locationLabels: Record; }; type BookingFormValues = { name: string; email: string; notes?: string; locationType?: LocationType; guests?: string[]; phone?: string; hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers customInputs?: { [key: string]: string | boolean; }; rescheduleReason?: string; }; const BookingPage = ({ eventType, booking, profile, isDynamicGroupBooking, recurringEventCount, locationLabels, hasHashedBookingLink, hashedLink, }: BookingPageProps) => { const { t, i18n } = useLocale(); const isEmbed = useIsEmbed(); const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; const router = useRouter(); const { contracts } = useContracts(); const { data: session } = useSession(); const isBackgroundTransparent = useIsBackgroundTransparent(); const telemetry = useTelemetry(); useEffect(() => { if (top !== window) { //page_view will be collected automatically by _middleware.ts telemetry.event( telemetryEventTypes.embedView, collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") }) ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (eventType.metadata.smartContractAddress) { const eventOwner = eventType.users[0]; if (!contracts[(eventType.metadata.smartContractAddress || null) as number]) router.replace(`/${eventOwner.username}`); } }, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]); const mutation = useMutation(createBooking, { onSuccess: async (responseData) => { const { id, attendees, paymentUid } = responseData; if (paymentUid) { return await router.push( createPaymentLink({ paymentUid, date, name: attendees[0].name, email: attendees[0].email, absolute: false, }) ); } const location = (function humanReadableLocation(location) { if (!location) { return; } if (location.includes("integration")) { return t("web_conferencing_details_to_follow"); } return location; })(responseData.location); return router.push({ pathname: "/success", query: { date, type: eventType.id, eventSlug: eventType.slug, user: profile.slug, reschedule: !!rescheduleUid, name: attendees[0].name, email: attendees[0].email, location, eventName: profile.eventName || "", bookingId: id, isSuccessBookingPage: true, }, }); }, }); const recurringMutation = useMutation(createRecurringBooking, { onSuccess: async (responseData = []) => { const { attendees = [], id, recurringEventId } = responseData[0] || {}; const location = (function humanReadableLocation(location) { if (!location) { return; } if (location.includes("integration")) { return t("web_conferencing_details_to_follow"); } return location; })(responseData[0].location); return router.push({ pathname: "/success", query: { date, type: eventType.id, eventSlug: eventType.slug, recur: recurringEventId, user: profile.slug, reschedule: !!rescheduleUid, name: attendees[0].name, email: attendees[0].email, location, eventName: profile.eventName || "", bookingId: id, }, }); }, }); const rescheduleUid = router.query.rescheduleUid as string; const { isReady, Theme } = useTheme(profile.theme); const date = asStringOrNull(router.query.date); const [guestToggle, setGuestToggle] = useState(booking && booking.attendees.length > 1); const eventTypeDetail = { isWeb3Active: false, ...eventType }; // it would be nice if Prisma at some point in the future allowed for Json; as of now this is not the case. const locations: LocationObject[] = useMemo( () => (eventType.locations as LocationObject[]) || [], [eventType.locations] ); useEffect(() => { if (router.query.guest) { setGuestToggle(true); } }, [router.query.guest]); const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id; const guestListEmails = !isDynamicGroupBooking ? booking?.attendees.slice(1).map((attendee) => attendee.email) : []; const defaultValues = () => { if (!rescheduleUid) { return { name: (router.query.name as string) || (!loggedInIsOwner && session?.user?.name) || "", email: (router.query.email as string) || (!loggedInIsOwner && session?.user?.email) || "", notes: (router.query.notes as string) || "", guests: ensureArray(router.query.guest) as string[], customInputs: eventType.customInputs.reduce( (customInputs, input) => ({ ...customInputs, [input.id]: router.query[slugify(input.label)], }), {} ), }; } if (!booking || !booking.attendees.length) { return {}; } const primaryAttendee = booking.attendees[0]; if (!primaryAttendee) { return {}; } const customInputType = booking.customInputs; return { name: primaryAttendee.name || "", email: primaryAttendee.email || "", guests: guestListEmails, notes: booking.description || "", rescheduleReason: "", customInputs: eventType.customInputs.reduce( (customInputs, input) => ({ ...customInputs, [input.id]: booking.customInputs ? booking.customInputs[input.label as keyof typeof customInputType] : "", }), {} ), }; }; const bookingFormSchema = z .object({ name: z.string().min(1), email: z.string().email(), }) .passthrough(); const bookingForm = useForm({ defaultValues: defaultValues(), resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema }); const selectedLocation = useWatch({ control: bookingForm.control, name: "locationType", defaultValue: ((): LocationType | undefined => { if (router.query.location) { return router.query.location as LocationType; } if (locations.length === 1) { return locations[0]?.type; } })(), }); const getLocationValue = ( booking: Pick ) => { const { locationType } = booking; switch (locationType) { case LocationType.Phone: { return booking.phone || ""; } case LocationType.InPerson: { return locationInfo(locationType)?.address || ""; } case LocationType.Link: { return locationInfo(locationType)?.link || ""; } case LocationType.UserPhone: { return locationInfo(locationType)?.hostPhoneNumber || ""; } // Catches all other location types, such as Google Meet, Zoom etc. default: return selectedLocation || ""; } }; // Calculate the booking date(s) let recurringStrings: string[] = [], recurringDates: Date[] = []; if (eventType.recurringEvent?.freq && recurringEventCount !== null) { [recurringStrings, recurringDates] = parseRecurringDates( { startDate: date, recurringEvent: eventType.recurringEvent, recurringCount: parseInt(recurringEventCount.toString()), }, i18n ); } const bookEvent = (booking: BookingFormValues) => { telemetry.event( top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed, { isTeamBooking: document.URL.includes("team/") } ); // "metadata" is a reserved key to allow for connecting external users without relying on the email address. // <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type. // @TODO: move to metadata const metadata = Object.keys(router.query) .filter((key) => key.startsWith("metadata")) .reduce( (metadata, key) => ({ ...metadata, [key.substring("metadata[".length, key.length - 1)]: router.query[key], }), {} ); let web3Details: Record<"userWallet" | "userSignature", string> | undefined; if (eventTypeDetail.metadata.smartContractAddress) { web3Details = { userWallet: window.web3.currentProvider.selectedAddress, userSignature: contracts[(eventTypeDetail.metadata.smartContractAddress || null) as number], }; } if (recurringDates.length) { // Identify set of bookings to one intance of recurring event to support batch changes const recurringEventId = uuidv4(); const recurringBookings = recurringDates.map((recurringDate) => ({ ...booking, web3Details, start: dayjs(recurringDate).format(), end: dayjs(recurringDate).add(eventType.length, "minute").format(), eventTypeId: eventType.id, eventTypeSlug: eventType.slug, recurringEventId, // Added to track down the number of actual occurrences selected by the user recurringCount: recurringDates.length, timeZone: timeZone(), language: i18n.language, rescheduleUid, user: router.query.user, location: getLocationValue( booking.locationType ? booking : { ...booking, locationType: selectedLocation } ), metadata, customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({ label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "", value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "", })), hasHashedBookingLink, hashedLink, })); recurringMutation.mutate(recurringBookings); } else { mutation.mutate({ ...booking, web3Details, start: dayjs(date).format(), end: dayjs(date).add(eventType.length, "minute").format(), eventTypeId: eventType.id, eventTypeSlug: eventType.slug, timeZone: timeZone(), language: i18n.language, rescheduleUid, bookingUid: router.query.bookingUid as string, user: router.query.user, location: getLocationValue( booking.locationType ? booking : { ...booking, locationType: selectedLocation } ), metadata, customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({ label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "", value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "", })), hasHashedBookingLink, hashedLink, }); } }; const disableInput = !!rescheduleUid; const disabledExceptForOwner = disableInput && !loggedInIsOwner; const inputClassName = "focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"; return (
{rescheduleUid ? t("booking_reschedule_confirmation", { eventTypeTitle: eventType.title, profileName: profile.name, }) : t("booking_confirmation", { eventTypeTitle: eventType.title, profileName: profile.name, })}{" "} | Cal.com
{isReady && (
user.name !== profile.name) .map((user) => ({ image: user.avatar || "", alt: user.name || "", })) )} />

{profile.name}

{eventType.title}

{!!eventType.seatsPerTimeSlot && (

= 0.5 ? "text-rose-600" : booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33 ? "text-yellow-500" : "text-emerald-400" } mb-2`}> {booking ? eventType.seatsPerTimeSlot - booking.attendees.length : eventType.seatsPerTimeSlot}{" "} / {eventType.seatsPerTimeSlot} {t("seats_available")}

)} {eventType?.description && (

{eventType.description}

)} {eventType?.requiresConfirmation && (

{t("requires_confirmation")}

)}

{eventType.length} {t("minutes")}

{eventType.price > 0 && (

)} {!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (

{getEveryFreqFor({ t, recurringEvent: eventType.recurringEvent, recurringCount: recurringEventCount, })}

)}
{(rescheduleUid || !eventType.recurringEvent?.freq) && parseDate(dayjs(date).tz(timeZone()), i18n)} {!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.slice(0, 5).map((aDate, key) =>

{aDate}

)} {!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
(

{aDate}

))}>

{t("plus_more", { count: recurringStrings.length - 5 })}

)}
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (

{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}

)} {booking?.startTime && rescheduleUid && (

{t("former_time")}

{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}

)}
{bookingForm.formState.errors.email && (

{t("email_validation_error")}

)}
{locations.length > 1 && (
{t("location")} {locations.map((location, i) => ( ))}
)} {selectedLocation === LocationType.Phone && (
control={bookingForm.control} name="phone" placeholder={t("enter_phone_number")} id="phone" required disabled={disableInput} />
)} {eventType.customInputs .sort((a, b) => a.id - b.id) .map((input) => (
{input.type !== EventTypeCustomInputType.BOOL && ( )} {input.type === EventTypeCustomInputType.TEXTLONG && (