import { CalendarIcon, ClockIcon, CreditCardIcon, ExclamationIcon, LocationMarkerIcon, } from "@heroicons/react/solid"; import { EventTypeCustomInputType } from "@prisma/client"; import dayjs from "dayjs"; 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 { createPaymentLink } from "@ee/lib/stripe/client"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; import { ensureArray } from "@lib/ensureArray"; import { useLocale } from "@lib/hooks/useLocale"; import useTheme from "@lib/hooks/useTheme"; import { LocationType } from "@lib/location"; import createBooking from "@lib/mutations/bookings/create-booking"; import { parseZone } from "@lib/parseZone"; import slugify from "@lib/slugify"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import CustomBranding from "@components/CustomBranding"; import { Form } from "@components/form/fields"; import AvatarGroup from "@components/ui/AvatarGroup"; import { Button } from "@components/ui/Button"; import PhoneInput from "@components/ui/form/PhoneInput"; import { BookPageProps } from "../../../pages/[user]/book"; import { TeamBookingPageProps } from "../../../pages/team/[slug]/book"; type BookingPageProps = BookPageProps | TeamBookingPageProps; const BookingPage = (props: BookingPageProps) => { const { t, i18n } = useLocale(); const router = useRouter(); /* * This was too optimistic * I started, then I remembered what a beast book/event.ts is * Gave up shortly after. One day. Maybe. * const mutation = trpc.useMutation("viewer.bookEvent", { onSuccess: ({ booking }) => { // go to success page. }, });*/ const mutation = useMutation(createBooking, { onSuccess: async ({ attendees, paymentUid, ...responseData }) => { if (paymentUid) { return await router.push( createPaymentLink({ paymentUid, date, name: attendees[0].name, 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: props.eventType.id, user: props.profile.slug, reschedule: !!rescheduleUid, name: attendees[0].name, email: attendees[0].email, location, }, }); }, }); const rescheduleUid = router.query.rescheduleUid as string; const { isReady } = useTheme(props.profile.theme); const date = asStringOrNull(router.query.date); const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma"; const [guestToggle, setGuestToggle] = useState(false); // it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case. const locations: { type: LocationType }[] = useMemo( () => (props.eventType.locations as { type: LocationType }[]) || [], [props.eventType.locations] ); useEffect(() => { if (router.query.guest) { setGuestToggle(true); } }, [router.query.guest]); const telemetry = useTelemetry(); useEffect(() => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters())); }, [telemetry]); const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); // TODO: Move to translations const locationLabels = { [LocationType.InPerson]: t("in_person_meeting"), [LocationType.Phone]: t("phone_call"), [LocationType.GoogleMeet]: "Google Meet", [LocationType.Zoom]: "Zoom Video", [LocationType.Daily]: "Daily.co Video", }; type BookingFormValues = { name: string; email: string; notes?: string; locationType?: LocationType; guests?: string[]; phone?: string; customInputs?: { [key: string]: string; }; }; const bookingForm = useForm<BookingFormValues>({ defaultValues: { name: (router.query.name as string) || "", email: (router.query.email as string) || "", notes: (router.query.notes as string) || "", guests: ensureArray(router.query.guest), customInputs: props.eventType.customInputs.reduce( (customInputs, input) => ({ ...customInputs, [input.id]: router.query[slugify(input.label)], }), {} ), }, }); 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<BookingFormValues, "locationType" | "phone">) => { const { locationType } = booking; switch (locationType) { case LocationType.Phone: { return booking.phone; } case LocationType.InPerson: { return locationInfo(locationType).address; } // Catches all other location types, such as Google Meet, Zoom etc. default: return selectedLocation; } }; const bookEvent = (booking: BookingFormValues) => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()) ); // "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. 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], }), {} ); mutation.mutate({ ...booking, start: dayjs(date).format(), end: dayjs(date).add(props.eventType.length, "minute").format(), eventTypeId: props.eventType.id, timeZone: timeZone(), language: i18n.language, rescheduleUid, user: router.query.user, location: getLocationValue(booking.locationType ? booking : { locationType: selectedLocation }), metadata, customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion value: booking.customInputs![inputId], })), }); }; return ( <div> <Head> <title> {rescheduleUid ? t("booking_reschedule_confirmation", { eventTypeTitle: props.eventType.title, profileName: props.profile.name, }) : t("booking_confirmation", { eventTypeTitle: props.eventType.title, profileName: props.profile.name, })}{" "} | Cal.com </title> <link rel="icon" href="/favicon.ico" /> </Head> <CustomBranding val={props.profile.brandColor} /> <main className="max-w-3xl mx-auto my-0 rounded-sm sm:my-24 sm:border sm:dark:border-gray-600"> {isReady && ( <div className="overflow-hidden bg-white border border-gray-200 dark:bg-neutral-900 dark:border-0 sm:rounded-sm"> <div className="px-4 py-5 sm:flex sm:p-4"> <div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800"> <AvatarGroup size={14} items={[{ image: props.profile.image, alt: props.profile.name }].concat( props.eventType.users .filter((user) => user.name !== props.profile.name) .map((user) => ({ image: user.avatar, title: user.name, })) )} /> <h2 className="mt-2 font-medium text-gray-500 font-cal dark:text-gray-300"> {props.profile.name} </h2> <h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white"> {props.eventType.title} </h1> <p className="mb-2 text-gray-500"> <ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> {props.eventType.length} {t("minutes")} </p> {props.eventType.price > 0 && ( <p className="px-2 py-1 mb-1 -ml-2 text-gray-500"> <CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> <IntlProvider locale="en"> <FormattedNumber value={props.eventType.price / 100.0} style="currency" currency={props.eventType.currency.toUpperCase()} /> </IntlProvider> </p> )} {selectedLocation === LocationType.InPerson && ( <p className="mb-2 text-gray-500"> <LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> {getLocationValue({ locationType: selectedLocation })} </p> )} <p className="mb-4 text-green-500"> <CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" /> {parseZone(date).format(timeFormat + ", dddd DD MMMM YYYY")} </p> <p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p> </div> <div className="sm:w-1/2 sm:pl-8 sm:pr-4"> <Form form={bookingForm} handleSubmit={bookEvent}> <div className="mb-4"> <label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white"> {t("your_name")} </label> <div className="mt-1"> <input {...bookingForm.register("name")} type="text" name="name" id="name" required className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" placeholder="John Doe" /> </div> </div> <div className="mb-4"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-white"> {t("email_address")} </label> <div className="mt-1"> <input {...bookingForm.register("email")} type="email" name="email" id="email" inputMode="email" required className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" placeholder="you@example.com" /> </div> </div> {locations.length > 1 && ( <div className="mb-4"> <span className="block text-sm font-medium text-gray-700 dark:text-white"> {t("location")} </span> {locations.map((location, i) => ( <label key={i} className="block"> <input type="radio" className="w-4 h-4 mr-2 text-black border-gray-300 location focus:ring-black" {...bookingForm.register("locationType", { required: true })} value={location.type} defaultChecked={selectedLocation === location.type} /> <span className="ml-2 text-sm dark:text-gray-500"> {locationLabels[location.type]} </span> </label> ))} </div> )} {selectedLocation === LocationType.Phone && ( <div className="mb-4"> <label htmlFor="phone" className="block text-sm font-medium text-gray-700 dark:text-white"> {t("phone_number")} </label> <div className="mt-1"> <PhoneInput name="phone" placeholder={t("enter_phone_number")} id="phone" required /> </div> </div> )} {props.eventType.customInputs .sort((a, b) => a.id - b.id) .map((input) => ( <div className="mb-4" key={input.id}> {input.type !== EventTypeCustomInputType.BOOL && ( <label htmlFor={"custom_" + input.id} className="block mb-1 text-sm font-medium text-gray-700 dark:text-white"> {input.label} </label> )} {input.type === EventTypeCustomInputType.TEXTLONG && ( <textarea {...bookingForm.register(`customInputs.${input.id}`, { required: input.required, })} id={"custom_" + input.id} rows={3} className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" placeholder={input.placeholder} /> )} {input.type === EventTypeCustomInputType.TEXT && ( <input type="text" {...bookingForm.register(`customInputs.${input.id}`, { required: input.required, })} id={"custom_" + input.id} className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" placeholder={input.placeholder} /> )} {input.type === EventTypeCustomInputType.NUMBER && ( <input type="number" {...bookingForm.register(`customInputs.${input.id}`, { required: input.required, })} id={"custom_" + input.id} className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" placeholder="" /> )} {input.type === EventTypeCustomInputType.BOOL && ( <div className="flex items-center h-5"> <input type="checkbox" {...bookingForm.register(`customInputs.${input.id}`, { required: input.required, })} id={"custom_" + input.id} className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black" placeholder="" /> <label htmlFor={"custom_" + input.id} className="block mb-1 text-sm font-medium text-gray-700 dark:text-white"> {input.label} </label> </div> )} </div> ))} {!props.eventType.disableGuests && ( <div className="mb-4"> {!guestToggle && ( <label onClick={() => setGuestToggle(!guestToggle)} htmlFor="guests" className="block mb-1 text-sm font-medium dark:text-white hover:cursor-pointer"> {/*<UserAddIcon className="inline-block w-5 h-5 mr-1 -mt-1" />*/} {t("additional_guests")} </label> )} {guestToggle && ( <div> <label htmlFor="guests" className="block mb-1 text-sm font-medium text-gray-700 dark:text-white"> {t("guests")} </label> <Controller control={bookingForm.control} name="guests" render={({ field: { onChange, value } }) => ( <ReactMultiEmail className="relative" placeholder="guest@example.com" emails={value} onChange={onChange} getLabel={( email: string, index: number, removeEmail: (index: number) => void ) => { return ( <div data-tag key={index}> {email} <span data-tag-handle onClick={() => removeEmail(index)}> × </span> </div> ); }} /> )} /> </div> )} </div> )} <div className="mb-4"> <label htmlFor="notes" className="block mb-1 text-sm font-medium text-gray-700 dark:text-white"> {t("additional_notes")} </label> <textarea {...bookingForm.register("notes")} id="notes" rows={3} className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" placeholder={t("share_additional_notes")} /> </div> <div className="flex items-start space-x-2"> <Button type="submit" loading={mutation.isLoading}> {rescheduleUid ? t("reschedule") : t("confirm")} </Button> <Button color="secondary" type="button" onClick={() => router.back()}> {t("cancel")} </Button> </div> </Form> {mutation.isError && ( <div className="p-4 mt-2 border-l-4 border-yellow-400 bg-yellow-50"> <div className="flex"> <div className="flex-shrink-0"> <ExclamationIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" /> </div> <div className="ml-3"> <p className="text-sm text-yellow-700"> {rescheduleUid ? t("reschedule_fail") : t("booking_fail")} </p> </div> </div> </div> )} </div> </div> </div> )} </main> </div> ); }; export default BookingPage;