cal.pub0.org/components/booking/pages/BookingPage.tsx

469 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 { stringify } from "querystring";
import { useCallback, useEffect, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import PhoneInput from "react-phone-number-input";
import "react-phone-number-input/style.css";
import { createPaymentLink } from "@ee/lib/stripe/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
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 { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { BookingCreateBody } from "@lib/types/booking";
import AvatarGroup from "@components/ui/AvatarGroup";
import { Button } from "@components/ui/Button";
import { BookPageProps } from "../../../pages/[user]/book";
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
type BookingPageProps = BookPageProps | TeamBookingPageProps;
const BookingPage = (props: BookingPageProps) => {
const router = useRouter();
const { rescheduleUid } = router.query;
const themeLoaded = useTheme(props.profile.theme);
const date = asStringOrNull(router.query.date);
const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma";
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [guestToggle, setGuestToggle] = useState(false);
const [guestEmails, setGuestEmails] = useState([]);
const locations = props.eventType.locations || [];
const [selectedLocation, setSelectedLocation] = useState<LocationType>(
locations.length === 1 ? locations[0].type : ""
);
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
}, []);
function toggleGuestEmailInput() {
setGuestToggle(!guestToggle);
}
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
// TODO: Move to translations
const locationLabels = {
[LocationType.InPerson]: "Link or In-person meeting",
[LocationType.Phone]: "Phone call",
[LocationType.GoogleMeet]: "Google Meet",
[LocationType.Zoom]: "Zoom Video",
};
const _bookingHandler = (event) => {
const book = async () => {
setLoading(true);
setError(false);
let notes = "";
if (props.eventType.customInputs) {
notes = props.eventType.customInputs
.map((input) => {
const data = event.target["custom_" + input.id];
if (data) {
if (input.type === EventTypeCustomInputType.BOOL) {
return input.label + "\n" + (data.checked ? "Yes" : "No");
} else {
return input.label + "\n" + data.value;
}
}
})
.join("\n\n");
}
if (!!notes && !!event.target.notes.value) {
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
const payload: BookingCreateBody = {
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
name: event.target.name.value,
email: event.target.email.value,
notes: notes,
guests: guestEmails,
eventTypeId: props.eventType.id,
timeZone: timeZone(),
};
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
if (typeof router.query.user === "string") payload.user = router.query.user;
if (selectedLocation) {
switch (selectedLocation) {
case LocationType.Phone:
payload["location"] = event.target.phone.value;
break;
case LocationType.InPerson:
payload["location"] = locationInfo(selectedLocation).address;
break;
// Catches all other location types, such as Google Meet, Zoom etc.
default:
payload["location"] = selectedLocation;
}
}
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
const content = await createBooking(payload).catch((e) => {
console.error(e.message);
setLoading(false);
setError(true);
});
if (content?.id) {
const params: { [k: string]: any } = {
date,
type: props.eventType.id,
user: props.profile.slug,
reschedule: !!rescheduleUid,
name: payload.name,
};
if (payload["location"]) {
if (payload["location"].includes("integration")) {
params.location = "Web conferencing details to follow.";
} else {
params.location = payload["location"];
}
}
const query = stringify(params);
let successUrl = `/success?${query}`;
if (content?.paymentUid) {
successUrl = createPaymentLink(content?.paymentUid, payload.name, date, false);
}
await router.push(successUrl);
} else {
setLoading(false);
setError(true);
}
};
event.preventDefault();
book();
};
const bookingHandler = useCallback(_bookingHandler, []);
return (
themeLoaded && (
<div>
<Head>
<title>
{rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with {props.profile.name}{" "}
| Cal.com
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-0 sm:my-24">
<div className="dark:bg-neutral-900 bg-white overflow-hidden border border-gray-200 dark:border-0 sm:rounded-sm">
<div className="sm:flex px-4 py-5 sm:p-4">
<div className="sm:w-1/2 sm:border-r sm:dark:border-black">
<AvatarGroup
size={16}
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="font-cal font-medium dark:text-gray-300 text-gray-500">
{props.profile.name}
</h2>
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
{props.eventType.title}
</h1>
<p className="text-gray-500 mb-2">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
{props.eventType.price > 0 && (
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
<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="text-gray-500 mb-2">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{locationInfo(selectedLocation).address}
</p>
)}
<p className="text-green-500 mb-4">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{parseZone(date).format(timeFormat + ", dddd DD MMMM YYYY")}
</p>
<p className="dark:text-white text-gray-600 mb-8">{props.eventType.description}</p>
</div>
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
<form onSubmit={bookingHandler}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium dark:text-white text-gray-700">
Your name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="John Doe"
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
/>
</div>
</div>
<div className="mb-4">
<label
htmlFor="email"
className="block text-sm font-medium dark:text-white text-gray-700">
Email address
</label>
<div className="mt-1">
<input
type="email"
name="email"
id="email"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="you@example.com"
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
/>
</div>
</div>
{locations.length > 1 && (
<div className="mb-4">
<span className="block text-sm font-medium dark:text-white text-gray-700">
Location
</span>
{locations.map((location) => (
<label key={location.type} className="block">
<input
type="radio"
required
onChange={(e) => setSelectedLocation(e.target.value)}
className="location focus:ring-black h-4 w-4 text-black border-gray-300 mr-2"
name="location"
value={location.type}
checked={selectedLocation === location.type}
/>
<span className="text-sm ml-2 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 dark:text-white text-gray-700">
Phone Number
</label>
<div className="mt-1">
<PhoneInput
name="phone"
placeholder="Enter phone number"
id="phone"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
onChange={() => {
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
}}
/>
</div>
</div>
)}
{props.eventType.customInputs &&
props.eventType.customInputs
.sort((a, b) => a.id - b.id)
.map((input) => (
<div className="mb-4" key={"input-" + input.label.toLowerCase}>
{input.type !== EventTypeCustomInputType.BOOL && (
<label
htmlFor={"custom_" + input.id}
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
rows={3}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
name={"custom_" + input.id}
id={"custom_" + input.id}
className="focus:ring-black h-4 w-4 text-black border-gray-300 rounded mr-2"
placeholder=""
required={input.required}
/>
<label
htmlFor={"custom_" + input.id}
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
{input.label}
</label>
</div>
)}
</div>
))}
{!props.eventType.disableGuests && (
<div className="mb-4">
{!guestToggle && (
<label
onClick={toggleGuestEmailInput}
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-blue-500 mb-1 hover:cursor-pointer">
+ Additional Guests
</label>
)}
{guestToggle && (
<div>
<label
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
Guests
</label>
<ReactMultiEmail
placeholder="guest@example.com"
emails={guestEmails}
onChange={(_emails: string[]) => {
setGuestEmails(_emails);
}}
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 text-sm font-medium dark:text-white text-gray-700 mb-1">
Additional notes
</label>
<textarea
name="notes"
id="notes"
rows={3}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="Please share anything that will help prepare for our meeting."
defaultValue={props.booking ? props.booking.description : ""}
/>
</div>
<div className="flex items-start space-x-2">
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
<Button type="submit" loading={loading}>
{rescheduleUid ? "Reschedule" : "Confirm"}
</Button>
<Button color="secondary" type="button" onClick={() => router.back()}>
Cancel
</Button>
</div>
</form>
{error && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mt-2">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Could not {rescheduleUid ? "reschedule" : "book"} the meeting.
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</main>
</div>
)
);
};
export default BookingPage;