import { zodResolver } from "@hookform/resolvers/zod"; import { EventTypeCustomInputType, WorkflowActions } from "@prisma/client"; import { useMutation } from "@tanstack/react-query"; import { isValidPhoneNumber } from "libphonenumber-js"; import { useSession } from "next-auth/react"; import Head from "next/head"; import { useRouter } from "next/router"; import { useEffect, useMemo, useReducer, useState } from "react"; import { useFieldArray, useForm, useWatch, Controller } from "react-hook-form"; import { FormattedNumber, IntlProvider } from "react-intl"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; import { EventLocationType, getEventLocationType, getEventLocationValue, getHumanReadableLocationValue, locationKeyToString, } from "@calcom/app-store/locations"; import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client"; import { getEventTypeAppData } from "@calcom/app-store/utils"; import { LocationObject, LocationType } from "@calcom/core/location"; import dayjs from "@calcom/dayjs"; import { useEmbedNonStylesConfig, useEmbedUiConfig, useIsBackgroundTransparent, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; import CustomBranding from "@calcom/lib/CustomBranding"; import classNames from "@calcom/lib/classNames"; import { APP_NAME } from "@calcom/lib/constants"; import getStripeAppData from "@calcom/lib/getStripeAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import { HttpError } from "@calcom/lib/http-error"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import slugify from "@calcom/lib/slugify"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { AddressInput, Button, EmailField, EmailInput, Form, Group, PhoneInput, RadioField, Tooltip, } from "@calcom/ui"; import { FiUserPlus, FiCalendar, FiX, FiInfo, FiCreditCard, FiRefreshCw, FiUser, FiAlertTriangle, } from "@calcom/ui/components/icon"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; import { ensureArray } from "@lib/ensureArray"; import useRouterQuery from "@lib/hooks/useRouterQuery"; import createBooking from "@lib/mutations/bookings/create-booking"; import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking"; import { parseDate, parseRecurringDates } from "@lib/parseDate"; import Gates, { Gate, GateState } from "@components/Gates"; import BookingDescription from "@components/booking/BookingDescription"; import { BookPageProps } from "../../../pages/[user]/book"; import { HashLinkPageProps } from "../../../pages/d/[link]/book"; import { TeamBookingPageProps } from "../../../pages/team/[slug]/book"; type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps; type BookingFormValues = { name: string; email: string; notes?: string; locationType?: EventLocationType["type"]; guests?: { email: string }[]; address?: string; attendeeAddress?: 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; smsReminderNumber?: string; }; const BookingPage = ({ eventType, booking, profile, isDynamicGroupBooking, recurringEventCount, hasHashedBookingLink, hashedLink, ...restProps }: BookingPageProps) => { const { t, i18n } = useLocale(); const { duration: queryDuration } = useRouterQuery("duration"); const isEmbed = useIsEmbed(restProps.isEmbed); const embedUiConfig = useEmbedUiConfig(); const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; const router = useRouter(); const { data: session } = useSession(); const isBackgroundTransparent = useIsBackgroundTransparent(); const telemetry = useTelemetry(); const [gateState, gateDispatcher] = useReducer( (state: GateState, newState: Partial) => ({ ...state, ...newState, }), {} ); const stripeAppData = getStripeAppData(eventType); // Define duration now that we support multiple duration eventTypes let duration = eventType.length; if ( queryDuration && !isNaN(Number(queryDuration)) && eventType.metadata?.multipleDuration && eventType.metadata?.multipleDuration.includes(Number(queryDuration)) ) { duration = Number(queryDuration); } 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 }, []); const mutation = useMutation(createBooking, { onSuccess: async (responseData) => { const { uid, paymentUid } = responseData; if (paymentUid) { return await router.push( createPaymentLink({ paymentUid, date, name: bookingForm.getValues("name"), email: bookingForm.getValues("email"), absolute: false, }) ); } return router.push({ pathname: `/booking/${uid}`, query: { isSuccessBookingPage: true, email: bookingForm.getValues("email"), eventTypeSlug: eventType.slug, formerTime: booking?.startTime.toString(), }, }); }, }); const recurringMutation = useMutation(createRecurringBooking, { onSuccess: async (responseData = []) => { const { uid } = responseData[0] || {}; return router.push({ pathname: `/booking/${uid}`, query: { allRemainingBookings: true, email: bookingForm.getValues("email"), eventTypeSlug: eventType.slug, formerTime: booking?.startTime.toString(), }, }); }, }); const rescheduleUid = router.query.rescheduleUid as string; useTheme(profile.theme); const date = asStringOrNull(router.query.date); const [guestToggle, setGuestToggle] = useState(booking && booking.attendees.length > 1); // 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 loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id; const guestListEmails = !isDynamicGroupBooking ? booking?.attendees.slice(1).map((attendee) => { return { email: attendee.email }; }) : []; // There should only exists one default userData variable for primaryAttendee. const defaultUserValues = { email: rescheduleUid ? booking?.attendees[0].email : router.query.email ? (router.query.email as string) : "", name: rescheduleUid ? booking?.attendees[0].name : router.query.name ? (router.query.name as string) : "", }; const defaultValues = () => { if (!rescheduleUid) { return { name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "", email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "", notes: (router.query.notes as string) || "", guests: ensureArray(router.query.guest).map((guest) => { return { email: 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: defaultUserValues.name, email: defaultUserValues.email || "", guests: guestListEmails, notes: booking.description || "", rescheduleReason: "", smsReminderNumber: booking.smsReminderNumber || undefined, 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().trim().email(), guests: z.array(z.object({ email: z.string().email() })).optional(), phone: z .string() .refine((val) => isValidPhoneNumber(val)) .optional() .nullable(), attendeeAddress: z.string().optional().nullable(), smsReminderNumber: z .string() .refine((val) => isValidPhoneNumber(val)) .optional() .nullable(), }) .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 guestsField = useFieldArray({ name: "guests", control: bookingForm.control, }); const selectedLocationType = useWatch({ control: bookingForm.control, name: "locationType", defaultValue: ((): EventLocationType["type"] | undefined => { if (router.query.location) { return router.query.location as EventLocationType["type"]; } if (locations.length === 1) { return locations[0]?.type; } })(), }); const selectedLocation = getEventLocationType(selectedLocationType); const AttendeeInput = selectedLocation?.attendeeInputType === "phone" ? PhoneInput : selectedLocation?.attendeeInputType === "attendeeAddress" ? AddressInput : null; // Calculate the booking date(s) let recurringStrings: string[] = [], recurringDates: Date[] = []; if (eventType.recurringEvent?.freq && recurringEventCount !== null) { [recurringStrings, recurringDates] = parseRecurringDates( { startDate: date, timeZone: timeZone(), recurringEvent: eventType.recurringEvent, recurringCount: parseInt(recurringEventCount.toString()), }, i18n ); } const bookEvent = (booking: BookingFormValues) => { bookingForm.clearErrors(); const bookingCustomInputs = Object.keys(booking.customInputs || {}).map((inputId) => ({ label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "", value: booking.customInputs && booking.customInputs[inputId] ? booking.customInputs[inputId] : "", })); // Checking if custom inputs of type Phone number are valid to display error message on UI if (eventType.customInputs.length) { let isErrorFound = false; eventType.customInputs.forEach((customInput) => { if (customInput.required && customInput.type === EventTypeCustomInputType.PHONE) { const input = bookingCustomInputs.find((i) => i.label === customInput.label); try { z.string({ errorMap: () => ({ message: `Missing ${customInput.type} customInput: '${customInput.label}'`, }), }) .refine((val) => isValidPhoneNumber(val), { message: "Phone number is invalid", }) .parse(input?.value); } catch (err) { isErrorFound = true; bookingForm.setError(`customInputs.${customInput.id}`, { type: "custom", message: "Invalid Phone number", }); } } }); if (isErrorFound) return; } 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], }), {} ); if (eventType.customInputs.length > 0) { // find all required custom inputs and ensure they are filled out in the booking form const requiredCustomInputs = eventType.customInputs.filter((input) => input.required); const missingRequiredCustomInputs = requiredCustomInputs.filter( (input) => !booking?.customInputs?.[input.id] ); if (missingRequiredCustomInputs.length > 0) { missingRequiredCustomInputs.forEach((input) => { bookingForm.setError(`customInputs.${input.id}`, { type: "required", }); }); return; } } // Validate that guests are unique let alreadyInvited = false; booking.guests?.forEach((guest, index) => { if (guest.email === booking.email) { bookingForm.setError(`guests.${index}`, { type: "validate", message: t("already_invited") }); alreadyInvited = true; } if (booking.guests) { let guestCount = 0; for (const checkGuest of booking.guests) { if (checkGuest.email === guest.email) guestCount++; if (guestCount > 1) { bookingForm.setError(`guests.${index}`, { type: "validate", message: t("already_invited") }); alreadyInvited = true; break; } } } }); if (alreadyInvited) return; 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, start: dayjs(recurringDate).format(), end: dayjs(recurringDate).add(duration, "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: getEventLocationValue(locations, { type: booking.locationType ? booking.locationType : selectedLocationType || "", phone: booking.phone, attendeeAddress: booking.attendeeAddress, }), metadata, customInputs: bookingCustomInputs, hasHashedBookingLink, hashedLink, smsReminderNumber: selectedLocationType === LocationType.Phone ? booking.phone : booking.smsReminderNumber || undefined, ethSignature: gateState.rainbowToken, guests: booking.guests?.map((guest) => guest.email), })); recurringMutation.mutate(recurringBookings); } else { mutation.mutate({ ...booking, start: dayjs(date).format(), end: dayjs(date).add(duration, "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: getEventLocationValue(locations, { type: (booking.locationType ? booking.locationType : selectedLocationType) || "", phone: booking.phone, attendeeAddress: booking.attendeeAddress, }), metadata, customInputs: bookingCustomInputs, hasHashedBookingLink, hashedLink, smsReminderNumber: selectedLocationType === LocationType.Phone ? booking.phone : booking.smsReminderNumber || undefined, ethSignature: gateState.rainbowToken, guests: booking.guests?.map((guest) => guest.email), }); } }; // Should be disabled when rescheduleUid is present and data was found in defaultUserValues name/email fields. const disableInput = !!rescheduleUid && !!defaultUserValues.email && !!defaultUserValues.name; const disableLocations = !!rescheduleUid; const disabledExceptForOwner = disableInput && !loggedInIsOwner; const inputClassName = "dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500"; let isSmsReminderNumberNeeded = false; let isSmsReminderNumberRequired = false; if (eventType.workflows.length > 0) { eventType.workflows.forEach((workflowReference) => { if (workflowReference.workflow.steps.length > 0) { workflowReference.workflow.steps.forEach((step) => { if (step.action === WorkflowActions.SMS_ATTENDEE) { isSmsReminderNumberNeeded = true; isSmsReminderNumberRequired = step.numberRequired || false; return; } }); } }); } const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed; const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {}; // Define conditional gates here const gates = [ // Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress` rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress ? ("rainbow" as Gate) : undefined, ]; return ( {rescheduleUid ? t("booking_reschedule_confirmation", { eventTypeTitle: eventType.title, profileName: profile.name, }) : t("booking_confirmation", { eventTypeTitle: eventType.title, profileName: profile.name, })}{" "} | {APP_NAME}
{showEventTypeDetails && (
{stripeAppData.price > 0 && (

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

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

)}
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`} {!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.slice(0, 5).map((timeFormatted, key) => { return

{timeFormatted}

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

{timeFormatted}

))}>

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

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

{t("former_time")}

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

)} {!!eventType.seatsPerTimeSlot && (
= 0.5 ? "text-rose-600" : booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33 ? "text-yellow-500" : "text-bookinghighlight" }`} />

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

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

{t("email_validation_error")}

)}
<> {rescheduleUid ? (
{t("location")}

{getHumanReadableLocationValue(booking?.location, t)}

) : ( locations.length > 1 && (
{t("location")} {locations.map((location, i) => { const locationString = locationKeyToString(location); if (!selectedLocationType) { bookingForm.setValue("locationType", locations[0].type); } if (typeof locationString !== "string") { // It's possible that location app got uninstalled return null; } return ( ); })}
) )} {/* TODO: Change name and id ="phone" to something generic */} {AttendeeInput && !disableInput && (
control={bookingForm.control} bookingForm={bookingForm} name={ selectedLocationType === LocationType.Phone ? "phone" : selectedLocationType === LocationType.AttendeeInPerson ? "attendeeAddress" : "" } placeholder={t(selectedLocation?.attendeeInputPlaceholder || "")} id={ selectedLocationType === LocationType.Phone ? "phone" : selectedLocationType === LocationType.AttendeeInPerson ? "attendeeAddress" : "" } required />
{bookingForm.formState.errors.phone && (

{t("invalid_number")}

)}
)} {eventType.customInputs .sort((a, b) => a.id - b.id) .map((input) => (
{input.type !== EventTypeCustomInputType.BOOL && ( )} {input.type === EventTypeCustomInputType.TEXTLONG && (