import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { useSession } from "next-auth/react"; import dynamic from "next/dynamic"; import Head from "next/head"; import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; import { useForm, useFormContext } from "react-hook-form"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; import type { EventLocationType } from "@calcom/app-store/locations"; import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations"; import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client"; import { getEventTypeAppData } from "@calcom/app-store/utils"; import type { LocationObject } from "@calcom/core/location"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { useEmbedNonStylesConfig, useEmbedUiConfig, useIsBackgroundTransparent, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; import { getBookingFieldsWithSystemFields, SystemField, } from "@calcom/features/bookings/lib/getBookingFields"; import getBookingResponsesSchema, { getBookingResponsesPartialSchema, } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; import { FormBuilderField } from "@calcom/features/form-builder/FormBuilder"; import CustomBranding from "@calcom/lib/CustomBranding"; import classNames from "@calcom/lib/classNames"; import { APP_NAME } from "@calcom/lib/constants"; 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 { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import type { RecurringEvent } from "@calcom/types/Calendar"; import { Button, Form, Tooltip } from "@calcom/ui"; import { FiAlertTriangle, FiCalendar, FiRefreshCw, FiUser } from "@calcom/ui/components/icon"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; 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 type { Gate, GateState } from "@components/Gates"; import Gates from "@components/Gates"; import BookingDescription from "@components/booking/BookingDescription"; import type { BookPageProps } from "../../../pages/[user]/book"; import type { HashLinkPageProps } from "../../../pages/d/[link]/book"; import type { TeamBookingPageProps } from "../../../pages/team/[slug]/book"; /** These are like 40kb that not every user needs */ const BookingDescriptionPayment = dynamic( () => import("@components/booking/BookingDescriptionPayment") ) as unknown as typeof import("@components/booking/BookingDescriptionPayment").default; type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps; const BookingFields = ({ fields, locations, rescheduleUid, isDynamicGroupBooking, }: { fields: BookingPageProps["eventType"]["bookingFields"]; locations: LocationObject[]; rescheduleUid?: string; isDynamicGroupBooking: boolean; }) => { const { t } = useLocale(); const { watch, setValue } = useFormContext(); const locationResponse = watch("responses.location"); return ( // TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
{fields.map((field, index) => { // During reschedule by default all system fields are readOnly. Make them editable on case by case basis. // Allowing a system field to be edited might require sending emails to attendees, so we need to be careful let readOnly = (field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid; let noLabel = false; let hidden = !!field.hidden; if (field.name === SystemField.Enum.rescheduleReason) { if (!rescheduleUid) { return null; } // rescheduleReason is a reschedule specific field and thus should be editable during reschedule readOnly = false; } if (field.name === SystemField.Enum.smsReminderNumber) { // `smsReminderNumber` and location.optionValue when location.value===phone are the same data point. We should solve it in a better way in the Form Builder itself. // I think we should have a way to connect 2 fields together and have them share the same value in Form Builder if (locationResponse?.value === "phone") { setValue(`responses.${SystemField.Enum.smsReminderNumber}`, locationResponse?.optionValue); // Just don't render the field now, as the value is already connected to attendee phone location return null; } // `smsReminderNumber` can be edited during reschedule even though it's a system field readOnly = false; } if (field.name === SystemField.Enum.guests) { // No matter what user configured for Guests field, we don't show it for dynamic group booking as that doesn't support guests hidden = isDynamicGroupBooking ? true : !!field.hidden; } // We don't show `notes` field during reschedule if ( (field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) && !!rescheduleUid ) { return null; } // Dynamically populate location field options if (field.name === SystemField.Enum.location && field.type === "radioInput") { if (!field.optionsInputs) { throw new Error("radioInput must have optionsInputs"); } const optionsInputs = field.optionsInputs; const options = locations.map((location) => { const eventLocation = getEventLocationType(location.type); const locationString = locationKeyToString(location); if (typeof locationString !== "string" || !eventLocation) { // It's possible that location app got uninstalled return null; } const type = eventLocation.type; const optionInput = optionsInputs[type as keyof typeof optionsInputs]; if (optionInput) { optionInput.placeholder = t(eventLocation?.attendeeInputPlaceholder || ""); } return { label: t(locationString), value: type, }; }); field.options = options.filter( (location): location is NonNullable<(typeof options)[number]> => !!location ); // If we have only one option and it has an input, we don't show the field label because Option name acts as label. // e.g. If it's just Attendee Phone Number option then we don't show `Location` label if (field.options.length === 1) { if (field.optionsInputs[field.options[0].value]) { noLabel = true; } else { // If there's only one option and it doesn't have an input, we don't show the field at all because it's visible in the left side bar hidden = true; } } } const label = noLabel ? "" : field.label || t(field.defaultLabel || ""); const placeholder = field.placeholder || t(field.defaultPlaceholder || ""); return (
); }; 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, }), {} ); // 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); } // This is a workaround for forcing the same time format for both server side rendering and client side rendering // At initial render, we use the default time format which is 12H const [withDefaultTimeFormat, setWithDefaultTimeFormat] = useState(true); const parseDateFunc = useCallback( (date: string | null | Dayjs) => { return parseDate(date, i18n, withDefaultTimeFormat); }, [withDefaultTimeFormat] ); // After intial render on client side, we let parseDateFunc to use the time format from the localStorage useEffect(() => { setWithDefaultTimeFormat(false); }, []); 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 } = responseData; if ("paymentUid" in responseData && !!responseData.paymentUid) { return await router.push( createPaymentLink({ paymentUid: responseData.paymentUid, date, name: bookingForm.getValues("responses.name"), email: bookingForm.getValues("responses.email"), absolute: false, }) ); } return router.push({ pathname: `/booking/${uid}`, query: { isSuccessBookingPage: true, email: bookingForm.getValues("responses.email"), eventTypeSlug: eventType.slug, seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null, ...(rescheduleUid && booking?.startTime && { 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("responses.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 querySchema = getBookingResponsesPartialSchema({ bookingFields: getBookingFieldsWithSystemFields(eventType), }); const parsedQuery = querySchema.parse({ ...router.query, // `guest` because we need to support legacy URL with `guest` query param support // `guests` because the `name` of the corresponding bookingField is `guests` guests: router.query.guests || router.query.guest, }); // 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] ); const [isClientTimezoneAvailable, setIsClientTimezoneAvailable] = useState(false); useEffect(() => { // THis is to fix hydration error that comes because of different timezone on server and client setIsClientTimezoneAvailable(true); }, []); const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id; // There should only exists one default userData variable for primaryAttendee. const defaultUserValues = { email: rescheduleUid ? booking?.attendees[0].email : parsedQuery["email"], name: rescheduleUid ? booking?.attendees[0].name : parsedQuery["name"], }; const defaultValues = () => { if (!rescheduleUid) { const defaults = { responses: {} as Partial["responses"]>, }; const responses = eventType.bookingFields.reduce((responses, field) => { return { ...responses, [field.name]: parsedQuery[field.name], }; }, {}); defaults.responses = { ...responses, name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "", email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "", }; return defaults; } if (!booking || !booking.attendees.length) { return {}; } const primaryAttendee = booking.attendees[0]; if (!primaryAttendee) { return {}; } const defaults = { responses: {} as Partial["responses"]>, }; const responses = eventType.bookingFields.reduce((responses, field) => { return { ...responses, [field.name]: booking.responses[field.name], }; }, {}); defaults.responses = { ...responses, name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "", email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "", }; return defaults; }; const bookingFormSchema = z .object({ responses: getBookingResponsesSchema({ bookingFields: getBookingFieldsWithSystemFields(eventType), }), }) .passthrough(); type BookingFormValues = { locationType?: EventLocationType["type"]; responses: z.infer["responses"]; }; const bookingForm = useForm({ defaultValues: defaultValues(), resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema }); // Calculate the booking date(s) let recurringStrings: string[] = [], recurringDates: Date[] = []; const parseRecurringDatesFunc = useCallback( (date: string | null | Dayjs, recurringEvent: RecurringEvent, recurringCount: number) => { return parseRecurringDates( { startDate: date, timeZone: timeZone(), recurringEvent: recurringEvent, recurringCount: recurringCount, withDefaultTimeFormat: withDefaultTimeFormat, }, i18n ); }, [withDefaultTimeFormat, date, eventType.recurringEvent, recurringEventCount] ); if (eventType.recurringEvent?.freq && recurringEventCount !== null) { [recurringStrings, recurringDates] = parseRecurringDatesFunc( date, eventType.recurringEvent, parseInt(recurringEventCount.toString()) ); } const bookEvent = (bookingValues: 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], }), {} ); 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) => ({ ...bookingValues, 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, metadata, hasHashedBookingLink, hashedLink, ethSignature: gateState.rainbowToken, })); recurringMutation.mutate(recurringBookings); } else { mutation.mutate({ ...bookingValues, start: dayjs(date).tz(timeZone()).format(), end: dayjs(date).tz(timeZone()).add(duration, "minute").format(), eventTypeId: eventType.id, eventTypeSlug: eventType.slug, timeZone: timeZone(), language: i18n.language, rescheduleUid, bookingUid: (router.query.bookingUid as string) || booking?.uid, user: router.query.user, metadata, hasHashedBookingLink, hashedLink, ethSignature: gateState.rainbowToken, seatReferenceUid: router.query.seatReferenceUid as string, }); } }; 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 && (
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (

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

)}
{isClientTimezoneAvailable && (rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDateFunc(date)}`} {isClientTimezoneAvailable && !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")}

{isClientTimezoneAvailable && typeof booking.startTime === "string" && parseDateFunc(dayjs(booking.startTime))}

)} {!!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")}

)}
)}
{(mutation.isError || recurringMutation.isError) && ( )}
); }; export default BookingPage; function ErrorMessage({ error }: { error: unknown }) { const { t } = useLocale(); const { query: { rescheduleUid } = {} } = useRouter(); const router = useRouter(); return (

{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "} {error instanceof HttpError || error instanceof Error ? ( <> {t("can_you_try_again")}{" "} router.back()}> {t("go_back")} . /* t(error.message) */ ) : ( "Unknown error" )}

); }