// TODO: replace headlessui with radix-ui import { Disclosure, RadioGroup } from "@headlessui/react"; import { PhoneIcon, XIcon } from "@heroicons/react/outline"; import { ChevronRightIcon, ClockIcon, DocumentIcon, ExternalLinkIcon, LinkIcon, LocationMarkerIcon, PlusIcon, TrashIcon, UserAddIcon, UsersIcon, } from "@heroicons/react/solid"; import { EventTypeCustomInput, EventTypeCustomInputType, Prisma, SchedulingType } from "@prisma/client"; import dayjs from "dayjs"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { GetServerSidePropsContext } from "next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { useRouter } from "next/router"; import React, { useEffect, useRef, useState } from "react"; import { FormattedNumber, IntlProvider } from "react-intl"; import { useMutation } from "react-query"; import Select, { OptionTypeBase } from "react-select"; import { StripeData } from "@ee/lib/stripe/server"; import { asNumberOrThrow, asNumberOrUndefined, asStringOrThrow, asStringOrUndefined, } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import classNames from "@lib/classNames"; import { HttpError } from "@lib/core/http/error"; import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils"; import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations"; import { LocationType } from "@lib/location"; import deleteEventType from "@lib/mutations/event-types/delete-event-type"; import updateEventType from "@lib/mutations/event-types/update-event-type"; import showToast from "@lib/notification"; import prisma from "@lib/prisma"; import { defaultAvatarSrc } from "@lib/profile"; import { AdvancedOptions, EventTypeInput } from "@lib/types/event-type"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import { Dialog, DialogTrigger } from "@components/Dialog"; import Modal from "@components/Modal"; import Shell from "@components/Shell"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import Button from "@components/ui/Button"; import { Scheduler } from "@components/ui/Scheduler"; import Switch from "@components/ui/Switch"; import CheckboxField from "@components/ui/form/CheckboxField"; import CheckedSelect from "@components/ui/form/CheckedSelect"; import { DateRangePicker } from "@components/ui/form/DateRangePicker"; import MinutesField from "@components/ui/form/MinutesField"; import * as RadioArea from "@components/ui/form/radio-area"; dayjs.extend(utc); dayjs.extend(timezone); const PERIOD_TYPES = [ { type: "rolling", suffix: "into the future", }, { type: "range", prefix: "Within a date range", }, { type: "unlimited", prefix: "Indefinitely into the future", }, ]; const EventTypePage = (props: inferSSRProps) => { const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } = props; const router = useRouter(); const [successModalOpen, setSuccessModalOpen] = useState(false); const inputOptions: OptionTypeBase[] = [ { value: EventTypeCustomInputType.TEXT, label: "Text" }, { value: EventTypeCustomInputType.TEXTLONG, label: "Multiline Text" }, { value: EventTypeCustomInputType.NUMBER, label: "Number" }, { value: EventTypeCustomInputType.BOOL, label: "Checkbox" }, ]; const updateMutation = useMutation(updateEventType, { onSuccess: async ({ eventType }) => { await router.push("/event-types"); showToast(`${eventType.title} event type updated successfully`, "success"); }, onError: (err: HttpError) => { const message = `${err.statusCode}: ${err.message}`; showToast(message, "error"); }, }); const deleteMutation = useMutation(deleteEventType, { onSuccess: async () => { await router.push("/event-types"); showToast("Event type deleted successfully", "success"); }, onError: (err: HttpError) => { const message = `${err.statusCode}: ${err.message}`; showToast(message, "error"); }, }); const [users, setUsers] = useState([]); const [enteredAvailability, setEnteredAvailability] = useState(); const [showLocationModal, setShowLocationModal] = useState(false); const [showAddCustomModal, setShowAddCustomModal] = useState(false); const [selectedTimeZone, setSelectedTimeZone] = useState(""); const [selectedLocation, setSelectedLocation] = useState(undefined); const [selectedInputOption, setSelectedInputOption] = useState(inputOptions[0]); const [locations, setLocations] = useState(eventType.locations || []); const [selectedCustomInput, setSelectedCustomInput] = useState(undefined); const [customInputs, setCustomInputs] = useState( eventType.customInputs.sort((a, b) => a.id - b.id) || [] ); const [periodType, setPeriodType] = useState(() => { return ( PERIOD_TYPES.find((s) => s.type === eventType.periodType) || PERIOD_TYPES.find((s) => s.type === "unlimited") ); }); const [requirePayment, setRequirePayment] = useState(eventType.price > 0); const [hidden, setHidden] = useState(eventType.hidden); const titleRef = useRef(null); const eventNameRef = useRef(null); const isAdvancedSettingsVisible = !!eventNameRef.current; useEffect(() => { setSelectedTimeZone(eventType.timeZone || ""); }, []); async function updateEventTypeHandler(event: React.FormEvent) { event.preventDefault(); const formData = Object.fromEntries(new FormData(event.currentTarget).entries()); const enteredTitle: string = titleRef.current!.value; const advancedPayload: AdvancedOptions = {}; if (isAdvancedSettingsVisible) { advancedPayload.eventName = eventNameRef.current.value; advancedPayload.periodType = periodType?.type; advancedPayload.periodDays = asNumberOrUndefined(formData.periodDays); advancedPayload.periodCountCalendarDays = Boolean( asNumberOrUndefined(formData.periodCountCalendarDays) ); advancedPayload.periodStartDate = periodDates.startDate || undefined; advancedPayload.periodEndDate = periodDates.endDate || undefined; advancedPayload.minimumBookingNotice = asNumberOrUndefined(formData.minimumBookingNotice); // prettier-ignore advancedPayload.price = !requirePayment ? undefined : formData.price ? Math.round(parseFloat(asStringOrThrow(formData.price)) * 100) : /* otherwise */ 0; advancedPayload.currency = currency; } const payload: EventTypeInput = { id: eventType.id, title: enteredTitle, slug: asStringOrThrow(formData.slug), description: asStringOrThrow(formData.description), length: asNumberOrThrow(formData.length), requiresConfirmation: formData.requiresConfirmation === "on", disableGuests: formData.disableGuests === "on", hidden, locations, customInputs, timeZone: selectedTimeZone, availability: enteredAvailability || undefined, ...advancedPayload, ...(team ? { schedulingType: formData.schedulingType as SchedulingType, users, } : {}), }; updateMutation.mutate(payload); } async function deleteEventTypeHandler(event: React.MouseEvent) { event.preventDefault(); const payload = { id: eventType.id }; deleteMutation.mutate(payload); } const openLocationModal = (type: LocationType) => { setSelectedLocation(locationOptions.find((option) => option.value === type)); setShowLocationModal(true); }; const closeLocationModal = () => { setSelectedLocation(undefined); setShowLocationModal(false); }; const closeAddCustomModal = () => { setSelectedInputOption(inputOptions[0]); setShowAddCustomModal(false); setSelectedCustomInput(undefined); }; const closeSuccessModal = () => { setSuccessModalOpen(false); }; const updateLocations = (e: React.FormEvent) => { e.preventDefault(); const newLocation = e.currentTarget.location.value; let details = {}; if (newLocation === LocationType.InPerson) { details = { address: e.currentTarget.address.value }; } const existingIdx = locations.findIndex((loc) => newLocation === loc.type); if (existingIdx !== -1) { const copy = locations; copy[existingIdx] = { ...locations[existingIdx], ...details }; setLocations(copy); } else { setLocations(locations.concat({ type: newLocation, ...details })); } setShowLocationModal(false); }; const removeLocation = (selectedLocation: typeof eventType.locations[number]) => { setLocations(locations.filter((location) => location.type !== selectedLocation.type)); }; const openEditCustomModel = (customInput: EventTypeCustomInput) => { setSelectedCustomInput(customInput); setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type)!); setShowAddCustomModal(true); }; const LocationOptions = () => { if (!selectedLocation) { return null; } switch (selectedLocation.value) { case LocationType.InPerson: return (
location.type === LocationType.InPerson)?.address} />
); case LocationType.Phone: return (

Cal will ask your invitee to enter a phone number before scheduling.

); case LocationType.GoogleMeet: return

Cal will provide a Google Meet location.

; case LocationType.Zoom: return

Cal will provide a Zoom meeting URL.

; } return null; }; const updateCustom = (e: React.FormEvent) => { e.preventDefault(); const customInput: EventTypeCustomInput = { id: -1, eventTypeId: -1, label: e.currentTarget.label.value, placeholder: e.currentTarget.placeholder?.value, required: e.currentTarget.required.checked, type: e.currentTarget.type.value, }; if (selectedCustomInput) { selectedCustomInput.label = customInput.label; selectedCustomInput.placeholder = customInput.placeholder; selectedCustomInput.required = customInput.required; selectedCustomInput.type = customInput.type; } else { setCustomInputs(customInputs.concat(customInput)); } closeAddCustomModal(); }; const removeCustom = (index: number) => { customInputs.splice(index, 1); setCustomInputs([...customInputs]); }; const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [ { value: SchedulingType.COLLECTIVE, label: "Collective", description: "Schedule meetings when all selected team members are available.", }, { value: SchedulingType.ROUND_ROBIN, label: "Round Robin", description: "Cycle meetings between multiple team members.", }, ]; const [periodDates, setPeriodDates] = useState<{ startDate: Date; endDate: Date }>({ startDate: new Date(eventType.periodStartDate || Date.now()), endDate: new Date(eventType.periodEndDate || Date.now()), }); const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/${ team ? `team/${team.slug}` : eventType.users[0].username }/${eventType.slug}`; const mapUserToValue = ({ id, name, avatar, }: { id: number | null; name: string | null; avatar: string | null; }) => ({ value: `${id || ""}`, label: `${name || ""}`, avatar: `${avatar || ""}`, }); return (
} subtitle={eventType.description || ""}>
{process.env.NEXT_PUBLIC_APP_URL?.replace(/^(https?:|)\/\//, "")}/ {team ? "team/" + team.slug : eventType.users[0].username}/
Duration } name="length" id="length" required placeholder="15" defaultValue={eventType.length} />

{locations.length === 0 && (
{team &&
} {team && (
setUsers(options.map((option) => option.value))} defaultValue={eventType.users.map(mapUserToValue)} options={teamMembers.map(mapUserToValue)} id="users" placeholder="Add attendees" />
)} {({ open }) => ( <> Show advanced settings
    {customInputs.map((customInput: EventTypeCustomInput, idx: number) => (
  • Label: {customInput.label}
    {customInput.placeholder && (
    Placeholder: {customInput.placeholder}
    )}
    Type: {customInput.type}
    {customInput.required ? "Required" : "Optional"}
  • ))}

Date Range
{PERIOD_TYPES.map((period) => ( classNames( checked ? "border-secondary-200 z-10" : "border-gray-200", "relative min-h-14 flex items-center cursor-pointer focus:outline-none" ) }> {({ active, checked }) => ( <>
{period.prefix ? {period.prefix}  : null} {period.type === "rolling" && (
)} {checked && period.type === "range" && (
)} {period.suffix ?  {period.suffix} : null}
)}
))}

{hasPaymentIntegration && ( <>
setRequirePayment(event.target.checked)} id="requirePayment" name="requirePayment" type="checkbox" className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded" defaultChecked={requirePayment} />

Require Payment (0.5% +{" "} {" "} commission per transaction)

{requirePayment && (
0 ? eventType.price / 100.0 : undefined } />
{new Intl.NumberFormat("en", { style: "currency", currency: currency, maximumSignificantDigits: 1, maximumFractionDigits: 0, }) .format(0) .replace("0", "")}
)}
)}
)}
Delete Are you sure you want to delete this event type? Anyone who you've shared this link with will no longer be able to book using it.
{showLocationModal && (
{(selectedInputOption.value === EventTypeCustomInputType.TEXT || selectedInputOption.value === EventTypeCustomInputType.TEXTLONG) && (
)}
)}
); }; export const getServerSideProps = async (context: GetServerSidePropsContext) => { const { req, query } = context; const session = await getSession({ req }); const locale = await extractLocaleInfo(context.req); const typeParam = parseInt(asStringOrThrow(query.type)); if (!session?.user?.id) { return { redirect: { permanent: false, destination: "/auth/login", }, }; } const userSelect = Prisma.validator()({ name: true, username: true, id: true, avatar: true, email: true, }); const rawEventType = await prisma.eventType.findFirst({ where: { AND: [ { OR: [ { users: { some: { id: session.user.id, }, }, }, { userId: session.user.id, }, ], }, { id: typeParam, }, ], }, select: { id: true, title: true, slug: true, description: true, length: true, hidden: true, locations: true, eventName: true, availability: true, customInputs: true, timeZone: true, periodType: true, periodDays: true, periodStartDate: true, periodEndDate: true, periodCountCalendarDays: true, requiresConfirmation: true, disableGuests: true, minimumBookingNotice: true, team: { select: { slug: true, members: { where: { accepted: true, }, select: { user: { select: userSelect, }, }, }, }, }, users: { select: userSelect, }, schedulingType: true, userId: true, price: true, currency: true, }, }); if (!rawEventType) throw Error("Event type not found"); type Location = { type: LocationType; address?: string; }; const { locations, ...restEventType } = rawEventType; const eventType = { ...restEventType, locations: locations as unknown as Location[], }; // backwards compat if (eventType.users.length === 0 && !eventType.team) { const fallbackUser = await prisma.user.findUnique({ where: { id: session.user.id, }, select: userSelect, }); if (!fallbackUser) throw Error("The event type doesn't have user and no fallback user was found"); eventType.users.push(fallbackUser); } const credentials = await prisma.credential.findMany({ where: { userId: session.user.id, }, select: { id: true, type: true, key: true, }, }); const integrations = getIntegrations(credentials); const locationOptions: OptionTypeBase[] = [ { value: LocationType.InPerson, label: "Link or In-person meeting" }, { value: LocationType.Phone, label: "Phone call" }, { value: LocationType.Zoom, label: "Zoom Video", disabled: true }, ]; const hasPaymentIntegration = hasIntegration(integrations, "stripe_payment"); if (hasIntegration(integrations, "google_calendar")) { locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" }); } const currency = (credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData) ?.default_currency || "usd"; if (hasIntegration(integrations, "office365_calendar")) { // TODO: Add default meeting option of the office integration. // Assuming it's Microsoft Teams. } type Availability = typeof eventType["availability"]; const getAvailability = (availability: Availability) => (availability?.length ? availability : null); const availability = getAvailability(eventType.availability) || []; availability.sort((a, b) => a.startTime - b.startTime); const eventTypeObject = Object.assign({}, eventType, { periodStartDate: eventType.periodStartDate?.toString() ?? null, periodEndDate: eventType.periodEndDate?.toString() ?? null, }); const teamMembers = eventTypeObject.team ? eventTypeObject.team.members.map((member) => { const user = member.user; user.avatar = user.avatar || defaultAvatarSrc({ email: asStringOrUndefined(user.email) }); return user; }) : []; return { props: { session, localeProp: locale, eventType: eventTypeObject, locationOptions, availability, team: eventTypeObject.team || null, teamMembers, hasPaymentIntegration, currency, ...(await serverSideTranslations(locale, ["common"])), }, }; }; export default EventTypePage;