diff --git a/apps/web/components/apps/installation/ConfigureStepCard.tsx b/apps/web/components/apps/installation/ConfigureStepCard.tsx index 282b01ecbdd070..ca61bd9e0be516 100644 --- a/apps/web/components/apps/installation/ConfigureStepCard.tsx +++ b/apps/web/components/apps/installation/ConfigureStepCard.tsx @@ -9,6 +9,7 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import type { LocationObject } from "@calcom/core/location"; +import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { AppCategories } from "@calcom/prisma/enums"; import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils"; @@ -17,8 +18,6 @@ import { Button, Form, Icon } from "@calcom/ui"; import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper"; import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings"; -import { locationsResolver } from "~/event-types/views/event-types-single-view"; - export type TFormType = { id: number; metadata: z.infer; diff --git a/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx b/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx index d0adca57dee4b1..c52eba4f8a4611 100644 --- a/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx +++ b/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx @@ -3,8 +3,7 @@ import { useEffect, type FC } from "react"; import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; - -import useAppsData from "@lib/hooks/useAppsData"; +import useAppsData from "@calcom/lib/hooks/useAppsData"; import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard"; diff --git a/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx b/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx index 1832ac7f03334b..0aebe425616a78 100644 --- a/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx +++ b/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx @@ -3,7 +3,11 @@ import { useMemo } from "react"; import { useFormContext } from "react-hook-form"; import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form"; +import type { TLocationOptions } from "@calcom/features/eventtypes/components/Locations"; +import type { TEventTypeLocation } from "@calcom/features/eventtypes/components/Locations"; +import Locations from "@calcom/features/eventtypes/components/Locations"; import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types"; +import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { SchedulingType } from "@calcom/prisma/client"; import { trpc } from "@calcom/trpc/react"; @@ -13,10 +17,6 @@ import { Skeleton, Label } from "@calcom/ui"; import { QueryCell } from "@lib/QueryCell"; import type { TFormType } from "@components/apps/installation/ConfigureStepCard"; -import type { TLocationOptions } from "@components/eventtype/Locations"; -import type { TEventTypeLocation } from "@components/eventtype/Locations"; -import Locations from "@components/eventtype/Locations"; -import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect"; const LocationsWrapper = ({ eventType, diff --git a/apps/web/components/dialog/EditLocationDialog.tsx b/apps/web/components/dialog/EditLocationDialog.tsx index 1c52f5d018cc64..09fa3f85347c4e 100644 --- a/apps/web/components/dialog/EditLocationDialog.tsx +++ b/apps/web/components/dialog/EditLocationDialog.tsx @@ -15,6 +15,9 @@ import { LocationType, OrganizerDefaultConferencingAppType, } from "@calcom/app-store/locations"; +import CheckboxField from "@calcom/features/form/components/CheckboxField"; +import type { LocationOption } from "@calcom/features/form/components/LocationSelect"; +import LocationSelect from "@calcom/features/form/components/LocationSelect"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -22,10 +25,6 @@ import { Button, Icon, Input, Dialog, DialogContent, DialogFooter, Form, PhoneIn import { QueryCell } from "@lib/QueryCell"; -import CheckboxField from "@components/ui/form/CheckboxField"; -import type { LocationOption } from "@components/ui/form/LocationSelect"; -import LocationSelect from "@components/ui/form/LocationSelect"; - type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; interface ISetLocationDialog { diff --git a/apps/web/components/eventtype/EventWorkfowsTab.tsx b/apps/web/components/eventtype/EventWorkfowsTab.tsx deleted file mode 100644 index 6baf823a7816b0..00000000000000 --- a/apps/web/components/eventtype/EventWorkfowsTab.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "@calcom/features/ee/workflows/components/EventWorkflowsTab"; diff --git a/apps/web/components/ui/form/CheckedSelect.tsx b/apps/web/components/ui/form/CheckedSelect.tsx index f1207b4a14af9e..fc47b79c5717cb 100644 --- a/apps/web/components/ui/form/CheckedSelect.tsx +++ b/apps/web/components/ui/form/CheckedSelect.tsx @@ -1,12 +1,11 @@ import React from "react"; import type { Props } from "react-select"; +import Select from "@calcom/features/form/components/Select"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Avatar } from "@calcom/ui"; import { Icon } from "@calcom/ui"; -import Select from "@components/ui/form/Select"; - type CheckedSelectOption = { avatar: string; label: string; diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index ca1b8131e0171f..5f41104e1304b7 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -14,6 +14,9 @@ import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/emb import { EventTypeDescription } from "@calcom/features/eventtypes/components"; import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog"; import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog"; +import SkeletonLoader, { + InfiniteSkeletonLoader, +} from "@calcom/features/eventtypes/components/SkeletonLoader"; import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter"; import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery"; import Shell from "@calcom/features/shell/Shell"; @@ -66,8 +69,6 @@ import type { AppProps } from "@lib/app-providers"; import { useInViewObserver } from "@lib/hooks/useInViewObserver"; import useMeQuery from "@lib/hooks/useMeQuery"; -import SkeletonLoader, { InfiniteSkeletonLoader } from "@components/eventtype/SkeletonLoader"; - type GetUserEventGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"]; type GetEventTypesFromGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getEventTypesFromGroup"]; diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index e5f7cfb245f271..0a256138f2cc5d 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -1,835 +1,16 @@ "use client"; +import { EventType } from "@calcom/features/eventtypes/components/EventType"; +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; + /* eslint-disable @typescript-eslint/no-empty-function */ -import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { isValidPhoneNumber } from "libphonenumber-js"; -import type { TFunction } from "next-i18next"; -import dynamic from "next/dynamic"; // eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router -import { useRouter } from "next/router"; -import { useEffect, useMemo, useState, useRef } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps"; -import { getEventLocationType } from "@calcom/app-store/locations"; -import { validateCustomEventName } from "@calcom/core/event"; -import { - DEFAULT_PROMPT_VALUE, - DEFAULT_BEGIN_MESSAGE, -} from "@calcom/features/ee/cal-ai-phone/promptTemplates"; -import type { Workflow } from "@calcom/features/ee/workflows/lib/types"; -import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect"; -import { sortHosts } from "@calcom/features/eventtypes/components/HostEditDialogs"; -import type { FormValues } from "@calcom/features/eventtypes/lib/types"; -import { validateIntervalLimitOrder } from "@calcom/lib"; -import { WEBSITE_URL } from "@calcom/lib/constants"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; -import { HttpError } from "@calcom/lib/http-error"; -import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; -import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; -import type { Prisma } from "@calcom/prisma/client"; -import { SchedulingType } from "@calcom/prisma/enums"; -import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; -import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; -import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; -import { Form, showToast } from "@calcom/ui"; import type { AppProps } from "@lib/app-providers"; -import { checkForEmptyAssignment } from "@lib/checkForEmptyAssignment"; - -import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout"; import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps"; -// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings; -const EventSetupTab = dynamic(() => - import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab) -); - -const EventAvailabilityTab = dynamic(() => - import("@components/eventtype/EventAvailabilityTab").then((mod) => mod.EventAvailabilityTab) -); - -const EventTeamTab = dynamic(() => - import("@components/eventtype/EventTeamTab").then((mod) => mod.EventTeamTab) -); - -const EventLimitsTab = dynamic(() => - import("@components/eventtype/EventLimitsTab").then((mod) => mod.EventLimitsTab) -); - -const EventAdvancedTab = dynamic(() => - import("@components/eventtype/EventAdvancedTab").then((mod) => mod.EventAdvancedTab) -); - -const EventInstantTab = dynamic(() => - import("@components/eventtype/EventInstantTab").then((mod) => mod.EventInstantTab) -); - -const EventRecurringTab = dynamic(() => - import("@components/eventtype/EventRecurringTab").then((mod) => mod.EventRecurringTab) -); - -const EventAppsTab = dynamic(() => - import("@components/eventtype/EventAppsTab").then((mod) => mod.EventAppsTab) -); - -const EventWorkflowsTab = dynamic(() => import("@components/eventtype/EventWorkfowsTab")); - -const EventWebhooksTab = dynamic(() => - import("@components/eventtype/EventWebhooksTab").then((mod) => mod.EventWebhooksTab) -); - -const EventAITab = dynamic(() => import("@components/eventtype/EventAITab").then((mod) => mod.EventAITab)); - -const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog")); - -const AssignmentWarningDialog = dynamic(() => import("@components/eventtype/AssignmentWarningDialog")); - -export type Host = { - isFixed: boolean; - userId: number; - priority: number; - weight: number; - weightAdjustment: number; -}; - -export type CustomInputParsed = typeof customInputSchema._output; - -const querySchema = z.object({ - tabName: z - .enum([ - "setup", - "availability", - "apps", - "limits", - "instant", - "recurring", - "team", - "advanced", - "workflows", - "webhooks", - "ai", - ]) - .optional() - .default("setup"), -}); - -export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"]; -export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]; -export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"]; -export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"]; - -export const locationsResolver = (t: TFunction) => { - return z - .array( - z - .object({ - type: z.string(), - address: z.string().optional(), - link: z.string().url().optional(), - phone: z - .string() - .refine((val) => isValidPhoneNumber(val)) - .optional(), - hostPhoneNumber: z - .string() - .refine((val) => isValidPhoneNumber(val)) - .optional(), - displayLocationPublicly: z.boolean().optional(), - credentialId: z.number().optional(), - teamName: z.string().optional(), - }) - .passthrough() - .superRefine((val, ctx) => { - if (val?.link) { - const link = val.link; - const eventLocationType = getEventLocationType(val.type); - if ( - eventLocationType && - !eventLocationType.default && - eventLocationType.linkType === "static" && - eventLocationType.urlRegExp - ) { - const valid = z.string().regex(new RegExp(eventLocationType.urlRegExp)).safeParse(link).success; - - if (!valid) { - const sampleUrl = eventLocationType.organizerInputPlaceholder; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [eventLocationType?.defaultValueVariable ?? "link"], - message: t("invalid_url_error_message", { - label: eventLocationType.label, - sampleUrl: sampleUrl ?? "https://cal.com", - }), - }); - } - return; - } - - const valid = z.string().url().optional().safeParse(link).success; - - if (!valid) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [eventLocationType?.defaultValueVariable ?? "link"], - message: `Invalid URL`, - }); - } - } - return; - }) - ) - .optional(); -}; - -const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[] }) => { - const { t } = useLocale(); - const utils = trpc.useUtils(); - const telemetry = useTelemetry(); - const { - data: { tabName }, - } = useTypedQuery(querySchema); - - const { data: eventTypeApps } = trpc.viewer.integrations.useQuery({ - extendsFeature: "EventType", - teamId: props.eventType.team?.id || props.eventType.parent?.teamId, - onlyInstalled: true, - }); - - const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props; - const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState(false); - const [pendingRoute, setPendingRoute] = useState(""); - const leaveWithoutAssigningHosts = useRef(false); - const isTeamEventTypeDeleted = useRef(false); - const [animationParentRef] = useAutoAnimate(); - const updateMutation = trpc.viewer.eventTypes.update.useMutation({ - onSuccess: async () => { - const currentValues = formMethods.getValues(); - - currentValues.children = currentValues.children.map((child) => ({ - ...child, - created: true, - })); - currentValues.assignAllTeamMembers = currentValues.assignAllTeamMembers || false; - - // Reset the form with these values as new default values to ensure the correct comparison for dirtyFields eval - formMethods.reset(currentValues); - - showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success"); - }, - async onSettled() { - await utils.viewer.eventTypes.get.invalidate(); - }, - onError: (err) => { - let message = ""; - if (err instanceof HttpError) { - const message = `${err.statusCode}: ${err.message}`; - showToast(message, "error"); - } - - if (err.data?.code === "UNAUTHORIZED") { - message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`; - } - - if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") { - message = `${err.data.code}: ${t(err.message)}`; - } - - if (err.data?.code === "INTERNAL_SERVER_ERROR") { - message = t("unexpected_error_try_again"); - } - - showToast(message ? t(message) : t(err.message), "error"); - }, - }); - - const router = useRouter(); - - const [periodDates] = useState<{ startDate: Date; endDate: Date }>({ - startDate: new Date(eventType.periodStartDate || Date.now()), - endDate: new Date(eventType.periodEndDate || Date.now()), - }); - - const bookingFields: Prisma.JsonObject = {}; - - eventType.bookingFields.forEach(({ name }) => { - bookingFields[name] = name; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const defaultValues: any = useMemo(() => { - return { - title: eventType.title, - id: eventType.id, - slug: eventType.slug, - afterEventBuffer: eventType.afterEventBuffer, - beforeEventBuffer: eventType.beforeEventBuffer, - eventName: eventType.eventName || "", - scheduleName: eventType.scheduleName, - periodDays: eventType.periodDays, - requiresBookerEmailVerification: eventType.requiresBookerEmailVerification, - seatsPerTimeSlot: eventType.seatsPerTimeSlot, - seatsShowAttendees: eventType.seatsShowAttendees, - seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount, - lockTimeZoneToggleOnBookingPage: eventType.lockTimeZoneToggleOnBookingPage, - locations: eventType.locations || [], - destinationCalendar: eventType.destinationCalendar, - recurringEvent: eventType.recurringEvent || null, - isInstantEvent: eventType.isInstantEvent, - instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds, - description: eventType.description ?? undefined, - schedule: eventType.schedule || undefined, - bookingLimits: eventType.bookingLimits || undefined, - onlyShowFirstAvailableSlot: eventType.onlyShowFirstAvailableSlot || undefined, - durationLimits: eventType.durationLimits || undefined, - length: eventType.length, - hidden: eventType.hidden, - hashedLink: eventType.hashedLink?.link || undefined, - eventTypeColor: eventType.eventTypeColor || null, - periodDates: { - startDate: periodDates.startDate, - endDate: periodDates.endDate, - }, - hideCalendarNotes: eventType.hideCalendarNotes, - offsetStart: eventType.offsetStart, - bookingFields: eventType.bookingFields, - periodType: eventType.periodType, - periodCountCalendarDays: eventType.periodCountCalendarDays ? true : false, - schedulingType: eventType.schedulingType, - requiresConfirmation: eventType.requiresConfirmation, - requiresConfirmationWillBlockSlot: eventType.requiresConfirmationWillBlockSlot, - slotInterval: eventType.slotInterval, - minimumBookingNotice: eventType.minimumBookingNotice, - metadata: eventType.metadata, - hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)), - successRedirectUrl: eventType.successRedirectUrl || "", - forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect, - users: eventType.users, - useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail, - secondaryEmailId: eventType?.secondaryEmailId || -1, - children: eventType.children.map((ch) => ({ - ...ch, - created: true, - owner: { - ...ch.owner, - eventTypeSlugs: - eventType.team?.members - .find((mem) => mem.user.id === ch.owner.id) - ?.user.eventTypes.map((evTy) => evTy.slug) - .filter((slug) => slug !== eventType.slug) ?? [], - }, - })), - seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot, - rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost, - assignAllTeamMembers: eventType.assignAllTeamMembers, - aiPhoneCallConfig: { - generalPrompt: eventType.aiPhoneCallConfig?.generalPrompt ?? DEFAULT_PROMPT_VALUE, - enabled: eventType.aiPhoneCallConfig?.enabled, - beginMessage: eventType.aiPhoneCallConfig?.beginMessage ?? DEFAULT_BEGIN_MESSAGE, - guestName: eventType.aiPhoneCallConfig?.guestName, - guestEmail: eventType.aiPhoneCallConfig?.guestEmail, - guestCompany: eventType.aiPhoneCallConfig?.guestCompany, - yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber, - numberToCall: eventType.aiPhoneCallConfig?.numberToCall, - templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE", - schedulerName: eventType.aiPhoneCallConfig?.schedulerName, - }, - isRRWeightsEnabled: eventType.isRRWeightsEnabled, - }; - }, [eventType, periodDates]); - const formMethods = useForm({ - defaultValues, - resolver: zodResolver( - z - .object({ - // Length if string, is converted to a number or it can be a number - // Make it optional because it's not submitted from all tabs of the page - eventName: z - .string() - .superRefine((val, ctx) => { - const validationResult = validateCustomEventName(val, bookingFields); - if (validationResult !== true) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("invalid_event_name_variables", { item: validationResult }), - }); - } - }) - .optional(), - length: z.union([z.string().transform((val) => +val), z.number()]).optional(), - offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(), - bookingFields: eventTypeBookingFields, - locations: locationsResolver(t), - }) - // TODO: Add schema for other fields later. - .passthrough() - ), - }); - const { - formState: { isDirty: isFormDirty, dirtyFields }, - } = formMethods; - - const onDelete = () => { - isTeamEventTypeDeleted.current = true; - }; - - useEffect(() => { - const handleRouteChange = (url: string) => { - const paths = url.split("/"); - - // If the event-type is deleted, we can't show the empty assignment warning - if (isTeamEventTypeDeleted.current) return; - - if ( - !!team && - !leaveWithoutAssigningHosts.current && - (url === "/event-types" || paths[1] !== "event-types") && - checkForEmptyAssignment({ - assignedUsers: eventType.children, - hosts: eventType.hosts, - assignAllTeamMembers: eventType.assignAllTeamMembers, - isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED, - }) - ) { - setIsOpenAssignmentWarnDialog(true); - setPendingRoute(url); - router.events.emit( - "routeChangeError", - new Error(`Aborted route change to ${url} because none was assigned to team event`) - ); - throw "Aborted"; - } - }; - router.events.on("routeChangeStart", handleRouteChange); - return () => { - router.events.off("routeChangeStart", handleRouteChange); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]); - - const appsMetadata = formMethods.getValues("metadata")?.apps; - const availability = formMethods.watch("availability"); - let numberOfActiveApps = 0; - - if (appsMetadata) { - numberOfActiveApps = Object.entries(appsMetadata).filter( - ([appId, appData]) => - eventTypeApps?.items.find((app) => app.slug === appId)?.isInstalled && appData.enabled - ).length; - } - - const permalink = `${WEBSITE_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${ - eventType.slug - }`; - const tabMap = { - setup: ( - - ), - availability: , - team: , - limits: , - advanced: , - instant: , - recurring: , - apps: , - workflows: props.allActiveWorkflows ? ( - - ) : ( - <> - ), - webhooks: , - ai: , - } as const; - const isObject = (value: T): boolean => { - return value !== null && typeof value === "object" && !Array.isArray(value); - }; - - const isArray = (value: T): boolean => { - return Array.isArray(value); - }; - - const isFieldDirty = (fieldName: keyof FormValues) => { - // If the field itself is directly marked as dirty - if (dirtyFields[fieldName] === true) { - return true; - } - - // Check if the field is an object or an array - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fieldValue: any = getNestedField(dirtyFields, fieldName); - if (isObject(fieldValue)) { - for (const key in fieldValue) { - if (fieldValue[key] === true) { - return true; - } - - if (isObject(fieldValue[key]) || isArray(fieldValue[key])) { - const nestedFieldName = `${fieldName}.${key}` as keyof FormValues; - // Recursive call for nested objects or arrays - if (isFieldDirty(nestedFieldName)) { - return true; - } - } - } - } - if (isArray(fieldValue)) { - for (const element of fieldValue) { - // If element is an object, check each property of the object - if (isObject(element)) { - for (const key in element) { - if (element[key] === true) { - return true; - } - - if (isObject(element[key]) || isArray(element[key])) { - const nestedFieldName = `${fieldName}.${key}` as keyof FormValues; - // Recursive call for nested objects or arrays within each element - if (isFieldDirty(nestedFieldName)) { - return true; - } - } - } - } else if (element === true) { - return true; - } - } - } - - return false; - }; - - const getNestedField = (obj: typeof dirtyFields, path: string) => { - const keys = path.split("."); - let current = obj; - - for (let i = 0; i < keys.length; i++) { - // @ts-expect-error /—— currentKey could be any deeply nested fields thanks to recursion - const currentKey = current[keys[i]]; - if (currentKey === undefined) return undefined; - current = currentKey; - } - - return current; - }; - - const getDirtyFields = (values: FormValues): Partial => { - if (!isFormDirty) { - return {}; - } - const updatedFields: Partial = {}; - Object.keys(dirtyFields).forEach((key) => { - const typedKey = key as keyof typeof dirtyFields; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - updatedFields[typedKey] = undefined; - const isDirty = isFieldDirty(typedKey); - if (isDirty) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - updatedFields[typedKey] = values[typedKey]; - } - }); - return updatedFields; - }; - - const handleSubmit = async (values: FormValues) => { - const { children } = values; - const dirtyValues = getDirtyFields(values); - const dirtyFieldExists = Object.keys(dirtyValues).length !== 0; - const { - periodDates, - periodCountCalendarDays, - beforeEventBuffer, - afterEventBuffer, - seatsPerTimeSlot, - seatsShowAttendees, - seatsShowAvailabilityCount, - bookingLimits, - onlyShowFirstAvailableSlot, - durationLimits, - recurringEvent, - eventTypeColor, - locations, - metadata, - customInputs, - assignAllTeamMembers, - // We don't need to send send these values to the backend - // eslint-disable-next-line @typescript-eslint/no-unused-vars - seatsPerTimeSlotEnabled, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - minimumBookingNoticeInDurationType, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - bookerLayouts, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - multipleDurationEnabled, - length, - ...input - } = dirtyValues; - if (!Number(length)) throw new Error(t("event_setup_length_error")); - - if (bookingLimits) { - const isValid = validateIntervalLimitOrder(bookingLimits); - if (!isValid) throw new Error(t("event_setup_booking_limits_error")); - } - - if (durationLimits) { - const isValid = validateIntervalLimitOrder(durationLimits); - if (!isValid) throw new Error(t("event_setup_duration_limits_error")); - } - - const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null); - if (layoutError) throw new Error(t(layoutError)); - - if (metadata?.multipleDuration !== undefined) { - if (metadata?.multipleDuration.length < 1) { - throw new Error(t("event_setup_multiple_duration_error")); - } else { - // if length is unchanged, we skip this check - if (length !== undefined) { - if (!length && !metadata?.multipleDuration?.includes(length)) { - //This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined - throw new Error(t("event_setup_multiple_duration_default_error")); - } - } - } - } - - // Prevent two payment apps to be enabled - // Ok to cast type here because this metadata will be updated as the event type metadata - if (checkForMultiplePaymentApps(metadata as z.infer)) - throw new Error(t("event_setup_multiple_payment_apps_error")); - - if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) { - throw new Error(t("seats_and_no_show_fee_error")); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { availability, users, scheduleName, ...rest } = input; - const payload = { - ...rest, - length, - locations, - recurringEvent, - periodStartDate: periodDates?.startDate, - periodEndDate: periodDates?.endDate, - periodCountCalendarDays, - id: eventType.id, - beforeEventBuffer, - afterEventBuffer, - bookingLimits, - onlyShowFirstAvailableSlot, - durationLimits, - eventTypeColor, - seatsPerTimeSlot, - seatsShowAttendees, - seatsShowAvailabilityCount, - metadata, - customInputs, - children, - assignAllTeamMembers, - }; - // Filter out undefined values - const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => { - if (value !== undefined) { - // @ts-expect-error Element implicitly has any type - acc[key] = value; - } - return acc; - }, {}); - - if (dirtyFieldExists) { - updateMutation.mutate({ ...filteredPayload, id: eventType.id }); - } - }; - - const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState([]); - const slug = formMethods.watch("slug") ?? eventType.slug; - - // Optional prerender all tabs after 300 ms on mount - useEffect(() => { - const timeout = setTimeout(() => { - const Components = [ - EventSetupTab, - EventAvailabilityTab, - EventTeamTab, - EventLimitsTab, - EventAdvancedTab, - EventInstantTab, - EventRecurringTab, - EventAppsTab, - EventWorkflowsTab, - EventWebhooksTab, - ]; - - Components.forEach((C) => { - // @ts-expect-error Property 'render' does not exist on type 'ComponentClass - C.render.preload(); - }); - }, 300); - - return () => { - clearTimeout(timeout); - }; - }, []); - return ( - <> - webhook.active).length} - team={team} - availability={availability} - isUpdateMutationLoading={updateMutation.isPending} - formMethods={formMethods} - // disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"} - disableBorder={true} - currentUserMembership={currentUserMembership} - bookerUrl={eventType.bookerUrl} - isUserOrganizationAdmin={props.isUserOrganizationAdmin} - onDelete={onDelete}> -
{ - const { children } = values; - const dirtyValues = getDirtyFields(values); - const dirtyFieldExists = Object.keys(dirtyValues).length !== 0; - const { - periodDates, - periodCountCalendarDays, - beforeEventBuffer, - afterEventBuffer, - seatsPerTimeSlot, - seatsShowAttendees, - seatsShowAvailabilityCount, - bookingLimits, - onlyShowFirstAvailableSlot, - durationLimits, - recurringEvent, - eventTypeColor, - locations, - metadata, - customInputs, - // We don't need to send send these values to the backend - // eslint-disable-next-line @typescript-eslint/no-unused-vars - seatsPerTimeSlotEnabled, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - multipleDurationEnabled, - length, - ...input - } = dirtyValues; - - if (length && !Number(length)) throw new Error(t("event_setup_length_error")); - - if (bookingLimits) { - const isValid = validateIntervalLimitOrder(bookingLimits); - if (!isValid) throw new Error(t("event_setup_booking_limits_error")); - } - - if (durationLimits) { - const isValid = validateIntervalLimitOrder(durationLimits); - if (!isValid) throw new Error(t("event_setup_duration_limits_error")); - } - - const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null); - if (layoutError) throw new Error(t(layoutError)); - - if (metadata?.multipleDuration !== undefined) { - if (metadata?.multipleDuration.length < 1) { - throw new Error(t("event_setup_multiple_duration_error")); - } else { - if (length !== undefined) { - if (!length && !metadata?.multipleDuration?.includes(length)) { - //This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined - throw new Error(t("event_setup_multiple_duration_default_error")); - } - } - } - } - - // Prevent two payment apps to be enabled - // Ok to cast type here because this metadata will be updated as the event type metadata - if (checkForMultiplePaymentApps(metadata as z.infer)) - throw new Error(t("event_setup_multiple_payment_apps_error")); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { availability, users, scheduleName, ...rest } = input; - const payload = { - ...rest, - children, - length, - locations, - recurringEvent, - periodStartDate: periodDates?.startDate, - periodEndDate: periodDates?.endDate, - periodCountCalendarDays, - id: eventType.id, - beforeEventBuffer, - afterEventBuffer, - bookingLimits, - onlyShowFirstAvailableSlot, - durationLimits, - eventTypeColor, - seatsPerTimeSlot, - seatsShowAttendees, - seatsShowAvailabilityCount, - metadata, - customInputs, - }; - // Filter out undefined values - const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => { - if (value !== undefined) { - // @ts-expect-error Element implicitly has any type - acc[key] = value; - } - return acc; - }, {}); - - if (dirtyFieldExists) { - updateMutation.mutate({ ...filteredPayload, id: eventType.id, hashedLink: values.hashedLink }); - } - }}> -
{tabMap[tabName]}
- -
- {slugExistsChildrenDialogOpen.length ? ( - { - setSlugExistsChildrenDialogOpen([]); - }} - slug={slug} - onConfirm={(e: { preventDefault: () => void }) => { - e.preventDefault(); - handleSubmit(formMethods.getValues()); - telemetry.event(telemetryEventTypes.slugReplacementAction); - setSlugExistsChildrenDialogOpen([]); - }} - /> - ) : null} - - - ); -}; const EventTypePageWrapper: React.FC & { PageWrapper?: AppProps["Component"]["PageWrapper"]; getLayout?: AppProps["Component"]["getLayout"]; @@ -855,7 +36,7 @@ const EventTypePageWrapper: React.FC & { allActiveWorkflows: workflows, }; - return ; + return ; }; export default EventTypePageWrapper; diff --git a/apps/web/pages/availability/index.tsx b/apps/web/pages/availability/index.tsx index 25c8540b5b4bee..8ca3eb6ec2fa42 100644 --- a/apps/web/pages/availability/index.tsx +++ b/apps/web/pages/availability/index.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useRouter, usePathname } from "next/navigation"; import { useCallback, useState } from "react"; +import SkeletonLoader from "@calcom/features/availability/components/SkeletonLoader"; import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal"; import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules"; import Shell from "@calcom/features/shell/Shell"; @@ -19,7 +20,6 @@ import { EmptyScreen, showToast, ToggleGroup } from "@calcom/ui"; import { QueryCell } from "@lib/QueryCell"; import PageWrapper from "@components/PageWrapper"; -import SkeletonLoader from "@components/availability/SkeletonLoader"; export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availability"]["list"]) { const { t } = useLocale(); diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 82887230383f14..5da0ca0e50ac8c 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -4,12 +4,12 @@ import EventTypePageWrapper from "~/event-types/views/event-types-single-view"; import { getServerSideProps } from "~/event-types/views/event-types-single-view.getServerSideProps"; export type { + FormValues, CustomInputParsed, EventTypeSetup, EventTypeSetupProps, Host, -} from "~/event-types/views/event-types-single-view"; -export type { FormValues } from "@calcom/features/eventtypes/lib/types"; +} from "@calcom/features/eventtypes/lib/types"; EventTypePageWrapper.PageWrapper = PageWrapper; diff --git a/apps/web/test/lib/CheckForEmptyAssignment.test.ts b/apps/web/test/lib/CheckForEmptyAssignment.test.ts index eba9c22853b342..4c968416bd60b1 100644 --- a/apps/web/test/lib/CheckForEmptyAssignment.test.ts +++ b/apps/web/test/lib/CheckForEmptyAssignment.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { checkForEmptyAssignment } from "../../lib/checkForEmptyAssignment"; +import { checkForEmptyAssignment } from "@calcom/lib/event-types/utils/checkForEmptyAssignment"; describe("Tests to Check if Event Types have empty Assignment", () => { it("should return true if managed event type has no assigned users", () => { diff --git a/apps/web/components/availability/SkeletonLoader.tsx b/packages/features/availability/components/SkeletonLoader.tsx similarity index 96% rename from apps/web/components/availability/SkeletonLoader.tsx rename to packages/features/availability/components/SkeletonLoader.tsx index 60ce2c71b7a033..bccb4bbf6bd0d7 100644 --- a/apps/web/components/availability/SkeletonLoader.tsx +++ b/packages/features/availability/components/SkeletonLoader.tsx @@ -1,7 +1,6 @@ +import classNames from "@calcom/lib/classNames"; import { Button, SkeletonText } from "@calcom/ui"; -import classNames from "@lib/classNames"; - function SkeletonLoader() { return (
    diff --git a/packages/features/eventtypes/components/EventType.tsx b/packages/features/eventtypes/components/EventType.tsx new file mode 100644 index 00000000000000..9b2ffe4647354b --- /dev/null +++ b/packages/features/eventtypes/components/EventType.tsx @@ -0,0 +1,759 @@ +"use client"; + +/* eslint-disable @typescript-eslint/no-empty-function */ +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import dynamic from "next/dynamic"; +// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps"; +import { validateCustomEventName } from "@calcom/core/event"; +import { + DEFAULT_PROMPT_VALUE, + DEFAULT_BEGIN_MESSAGE, +} from "@calcom/features/ee/cal-ai-phone/promptTemplates"; +import type { Workflow } from "@calcom/features/ee/workflows/lib/types"; +import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect"; +import { sortHosts } from "@calcom/features/eventtypes/components/HostEditDialogs"; +import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import { validateIntervalLimitOrder } from "@calcom/lib"; +import { WEBSITE_URL } from "@calcom/lib/constants"; +import { checkForEmptyAssignment } from "@calcom/lib/event-types/utils/checkForEmptyAssignment"; +import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; +import { HttpError } from "@calcom/lib/http-error"; +import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; +import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; +import type { Prisma } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; +import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import { Form, showToast } from "@calcom/ui"; + +import { EventTypeSingleLayout } from "./EventTypeLayout"; + +// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings; +const EventSetupTab = dynamic(() => import("./tabs/setup/EventSetupTab").then((mod) => mod.EventSetupTab)); + +const EventAvailabilityTab = dynamic(() => + import("./tabs/availability/EventAvailabilityTab").then((mod) => mod.EventAvailabilityTab) +); + +const EventTeamAssignmentTab = dynamic(() => + import("./tabs/assignment/EventTeamAssignmentTab").then((mod) => mod.EventTeamAssignmentTab) +); + +const EventLimitsTab = dynamic(() => + import("./tabs/limits/EventLimitsTab").then((mod) => mod.EventLimitsTab) +); + +const EventAdvancedTab = dynamic(() => + import("./tabs/advanced/EventAdvancedTab").then((mod) => mod.EventAdvancedTab) +); + +const EventInstantTab = dynamic(() => + import("./tabs/instant/EventInstantTab").then((mod) => mod.EventInstantTab) +); + +const EventRecurringTab = dynamic(() => + import("./tabs/recurring/EventRecurringTab").then((mod) => mod.EventRecurringTab) +); + +const EventAppsTab = dynamic(() => import("./tabs/apps/EventAppsTab").then((mod) => mod.EventAppsTab)); + +const EventWorkflowsTab = dynamic(() => import("./tabs/workflows/EventWorkfowsTab")); + +const EventWebhooksTab = dynamic(() => + import("./tabs/webhooks/EventWebhooksTab").then((mod) => mod.EventWebhooksTab) +); + +const EventAITab = dynamic(() => import("./tabs/ai/EventAITab").then((mod) => mod.EventAITab)); + +const ManagedEventTypeDialog = dynamic(() => import("./dialogs/ManagedEventDialog")); + +const AssignmentWarningDialog = dynamic(() => import("./dialogs/AssignmentWarningDialog")); + +export type Host = { + isFixed: boolean; + userId: number; + priority: number; + weight: number; + weightAdjustment: number; +}; + +export type CustomInputParsed = typeof customInputSchema._output; + +const querySchema = z.object({ + tabName: z + .enum([ + "setup", + "availability", + "apps", + "limits", + "instant", + "recurring", + "team", + "advanced", + "workflows", + "webhooks", + "ai", + ]) + .optional() + .default("setup"), +}); + +export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"]; +export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]; +export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"]; +export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"]; + +export const EventType = (props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[] }) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const telemetry = useTelemetry(); + const { + data: { tabName }, + } = useTypedQuery(querySchema); + + const { data: eventTypeApps } = trpc.viewer.integrations.useQuery({ + extendsFeature: "EventType", + teamId: props.eventType.team?.id || props.eventType.parent?.teamId, + onlyInstalled: true, + }); + + const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props; + const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState(false); + const [pendingRoute, setPendingRoute] = useState(""); + const leaveWithoutAssigningHosts = useRef(false); + const isTeamEventTypeDeleted = useRef(false); + const [animationParentRef] = useAutoAnimate(); + const updateMutation = trpc.viewer.eventTypes.update.useMutation({ + onSuccess: async () => { + const currentValues = formMethods.getValues(); + + currentValues.children = currentValues.children.map((child) => ({ + ...child, + created: true, + })); + currentValues.assignAllTeamMembers = currentValues.assignAllTeamMembers || false; + + // Reset the form with these values as new default values to ensure the correct comparison for dirtyFields eval + formMethods.reset(currentValues); + + showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success"); + }, + async onSettled() { + await utils.viewer.eventTypes.get.invalidate(); + }, + onError: (err) => { + let message = ""; + if (err instanceof HttpError) { + const message = `${err.statusCode}: ${err.message}`; + showToast(message, "error"); + } + + if (err.data?.code === "UNAUTHORIZED") { + message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`; + } + + if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") { + message = `${err.data.code}: ${t(err.message)}`; + } + + if (err.data?.code === "INTERNAL_SERVER_ERROR") { + message = t("unexpected_error_try_again"); + } + + showToast(message ? t(message) : t(err.message), "error"); + }, + }); + + const router = useRouter(); + + const [periodDates] = useState<{ startDate: Date; endDate: Date }>({ + startDate: new Date(eventType.periodStartDate || Date.now()), + endDate: new Date(eventType.periodEndDate || Date.now()), + }); + + const bookingFields: Prisma.JsonObject = {}; + + eventType.bookingFields.forEach(({ name }) => { + bookingFields[name] = name; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const defaultValues: any = useMemo(() => { + return { + title: eventType.title, + id: eventType.id, + slug: eventType.slug, + afterEventBuffer: eventType.afterEventBuffer, + beforeEventBuffer: eventType.beforeEventBuffer, + eventName: eventType.eventName || "", + scheduleName: eventType.scheduleName, + periodDays: eventType.periodDays, + requiresBookerEmailVerification: eventType.requiresBookerEmailVerification, + seatsPerTimeSlot: eventType.seatsPerTimeSlot, + seatsShowAttendees: eventType.seatsShowAttendees, + seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount, + lockTimeZoneToggleOnBookingPage: eventType.lockTimeZoneToggleOnBookingPage, + locations: eventType.locations || [], + destinationCalendar: eventType.destinationCalendar, + recurringEvent: eventType.recurringEvent || null, + isInstantEvent: eventType.isInstantEvent, + instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds, + description: eventType.description ?? undefined, + schedule: eventType.schedule || undefined, + bookingLimits: eventType.bookingLimits || undefined, + onlyShowFirstAvailableSlot: eventType.onlyShowFirstAvailableSlot || undefined, + durationLimits: eventType.durationLimits || undefined, + length: eventType.length, + hidden: eventType.hidden, + hashedLink: eventType.hashedLink?.link || undefined, + eventTypeColor: eventType.eventTypeColor || null, + periodDates: { + startDate: periodDates.startDate, + endDate: periodDates.endDate, + }, + hideCalendarNotes: eventType.hideCalendarNotes, + offsetStart: eventType.offsetStart, + bookingFields: eventType.bookingFields, + periodType: eventType.periodType, + periodCountCalendarDays: eventType.periodCountCalendarDays ? true : false, + schedulingType: eventType.schedulingType, + requiresConfirmation: eventType.requiresConfirmation, + requiresConfirmationWillBlockSlot: eventType.requiresConfirmationWillBlockSlot, + slotInterval: eventType.slotInterval, + minimumBookingNotice: eventType.minimumBookingNotice, + metadata: eventType.metadata, + hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)), + successRedirectUrl: eventType.successRedirectUrl || "", + forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect, + users: eventType.users, + useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail, + secondaryEmailId: eventType?.secondaryEmailId || -1, + children: eventType.children.map((ch) => ({ + ...ch, + created: true, + owner: { + ...ch.owner, + eventTypeSlugs: + eventType.team?.members + .find((mem) => mem.user.id === ch.owner.id) + ?.user.eventTypes.map((evTy) => evTy.slug) + .filter((slug) => slug !== eventType.slug) ?? [], + }, + })), + seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot, + rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost, + assignAllTeamMembers: eventType.assignAllTeamMembers, + aiPhoneCallConfig: { + generalPrompt: eventType.aiPhoneCallConfig?.generalPrompt ?? DEFAULT_PROMPT_VALUE, + enabled: eventType.aiPhoneCallConfig?.enabled, + beginMessage: eventType.aiPhoneCallConfig?.beginMessage ?? DEFAULT_BEGIN_MESSAGE, + guestName: eventType.aiPhoneCallConfig?.guestName, + guestEmail: eventType.aiPhoneCallConfig?.guestEmail, + guestCompany: eventType.aiPhoneCallConfig?.guestCompany, + yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber, + numberToCall: eventType.aiPhoneCallConfig?.numberToCall, + templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE", + schedulerName: eventType.aiPhoneCallConfig?.schedulerName, + }, + isRRWeightsEnabled: eventType.isRRWeightsEnabled, + }; + }, [eventType, periodDates]); + const formMethods = useForm({ + defaultValues, + resolver: zodResolver( + z + .object({ + // Length if string, is converted to a number or it can be a number + // Make it optional because it's not submitted from all tabs of the page + eventName: z + .string() + .superRefine((val, ctx) => { + const validationResult = validateCustomEventName(val, bookingFields); + if (validationResult !== true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("invalid_event_name_variables", { item: validationResult }), + }); + } + }) + .optional(), + length: z.union([z.string().transform((val) => +val), z.number()]).optional(), + offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(), + bookingFields: eventTypeBookingFields, + locations: locationsResolver(t), + }) + // TODO: Add schema for other fields later. + .passthrough() + ), + }); + const { + formState: { isDirty: isFormDirty, dirtyFields }, + } = formMethods; + + const onDelete = () => { + isTeamEventTypeDeleted.current = true; + }; + + useEffect(() => { + const handleRouteChange = (url: string) => { + const paths = url.split("/"); + + // If the event-type is deleted, we can't show the empty assignment warning + if (isTeamEventTypeDeleted.current) return; + + if ( + !!team && + !leaveWithoutAssigningHosts.current && + (url === "/event-types" || paths[1] !== "event-types") && + checkForEmptyAssignment({ + assignedUsers: eventType.children, + hosts: eventType.hosts, + assignAllTeamMembers: eventType.assignAllTeamMembers, + isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED, + }) + ) { + setIsOpenAssignmentWarnDialog(true); + setPendingRoute(url); + router.events.emit( + "routeChangeError", + new Error(`Aborted route change to ${url} because none was assigned to team event`) + ); + throw "Aborted"; + } + }; + router.events.on("routeChangeStart", handleRouteChange); + return () => { + router.events.off("routeChangeStart", handleRouteChange); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]); + + const appsMetadata = formMethods.getValues("metadata")?.apps; + const availability = formMethods.watch("availability"); + let numberOfActiveApps = 0; + + if (appsMetadata) { + numberOfActiveApps = Object.entries(appsMetadata).filter( + ([appId, appData]) => + eventTypeApps?.items.find((app) => app.slug === appId)?.isInstalled && appData.enabled + ).length; + } + + const permalink = `${WEBSITE_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${ + eventType.slug + }`; + const tabMap = { + setup: ( + + ), + availability: , + team: , + limits: , + advanced: , + instant: , + recurring: , + apps: , + workflows: props.allActiveWorkflows ? ( + + ) : ( + <> + ), + webhooks: , + ai: , + } as const; + const isObject = (value: T): boolean => { + return value !== null && typeof value === "object" && !Array.isArray(value); + }; + + const isArray = (value: T): boolean => { + return Array.isArray(value); + }; + + const isFieldDirty = (fieldName: keyof FormValues) => { + // If the field itself is directly marked as dirty + if (dirtyFields[fieldName] === true) { + return true; + } + + // Check if the field is an object or an array + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fieldValue: any = getNestedField(dirtyFields, fieldName); + if (isObject(fieldValue)) { + for (const key in fieldValue) { + if (fieldValue[key] === true) { + return true; + } + + if (isObject(fieldValue[key]) || isArray(fieldValue[key])) { + const nestedFieldName = `${fieldName}.${key}` as keyof FormValues; + // Recursive call for nested objects or arrays + if (isFieldDirty(nestedFieldName)) { + return true; + } + } + } + } + if (isArray(fieldValue)) { + for (const element of fieldValue) { + // If element is an object, check each property of the object + if (isObject(element)) { + for (const key in element) { + if (element[key] === true) { + return true; + } + + if (isObject(element[key]) || isArray(element[key])) { + const nestedFieldName = `${fieldName}.${key}` as keyof FormValues; + // Recursive call for nested objects or arrays within each element + if (isFieldDirty(nestedFieldName)) { + return true; + } + } + } + } else if (element === true) { + return true; + } + } + } + + return false; + }; + + const getNestedField = (obj: typeof dirtyFields, path: string) => { + const keys = path.split("."); + let current = obj; + + for (let i = 0; i < keys.length; i++) { + // @ts-expect-error /—— currentKey could be any deeply nested fields thanks to recursion + const currentKey = current[keys[i]]; + if (currentKey === undefined) return undefined; + current = currentKey; + } + + return current; + }; + + const getDirtyFields = (values: FormValues): Partial => { + if (!isFormDirty) { + return {}; + } + const updatedFields: Partial = {}; + Object.keys(dirtyFields).forEach((key) => { + const typedKey = key as keyof typeof dirtyFields; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + updatedFields[typedKey] = undefined; + const isDirty = isFieldDirty(typedKey); + if (isDirty) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + updatedFields[typedKey] = values[typedKey]; + } + }); + return updatedFields; + }; + + const handleSubmit = async (values: FormValues) => { + const { children } = values; + const dirtyValues = getDirtyFields(values); + const dirtyFieldExists = Object.keys(dirtyValues).length !== 0; + const { + periodDates, + periodCountCalendarDays, + beforeEventBuffer, + afterEventBuffer, + seatsPerTimeSlot, + seatsShowAttendees, + seatsShowAvailabilityCount, + bookingLimits, + onlyShowFirstAvailableSlot, + durationLimits, + recurringEvent, + eventTypeColor, + locations, + metadata, + customInputs, + assignAllTeamMembers, + // We don't need to send send these values to the backend + // eslint-disable-next-line @typescript-eslint/no-unused-vars + seatsPerTimeSlotEnabled, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + minimumBookingNoticeInDurationType, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + bookerLayouts, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multipleDurationEnabled, + length, + ...input + } = dirtyValues; + if (!Number(length)) throw new Error(t("event_setup_length_error")); + + if (bookingLimits) { + const isValid = validateIntervalLimitOrder(bookingLimits); + if (!isValid) throw new Error(t("event_setup_booking_limits_error")); + } + + if (durationLimits) { + const isValid = validateIntervalLimitOrder(durationLimits); + if (!isValid) throw new Error(t("event_setup_duration_limits_error")); + } + + const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null); + if (layoutError) throw new Error(t(layoutError)); + + if (metadata?.multipleDuration !== undefined) { + if (metadata?.multipleDuration.length < 1) { + throw new Error(t("event_setup_multiple_duration_error")); + } else { + // if length is unchanged, we skip this check + if (length !== undefined) { + if (!length && !metadata?.multipleDuration?.includes(length)) { + //This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined + throw new Error(t("event_setup_multiple_duration_default_error")); + } + } + } + } + + // Prevent two payment apps to be enabled + // Ok to cast type here because this metadata will be updated as the event type metadata + if (checkForMultiplePaymentApps(metadata as z.infer)) + throw new Error(t("event_setup_multiple_payment_apps_error")); + + if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) { + throw new Error(t("seats_and_no_show_fee_error")); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { availability, users, scheduleName, ...rest } = input; + const payload = { + ...rest, + length, + locations, + recurringEvent, + periodStartDate: periodDates?.startDate, + periodEndDate: periodDates?.endDate, + periodCountCalendarDays, + id: eventType.id, + beforeEventBuffer, + afterEventBuffer, + bookingLimits, + onlyShowFirstAvailableSlot, + durationLimits, + eventTypeColor, + seatsPerTimeSlot, + seatsShowAttendees, + seatsShowAvailabilityCount, + metadata, + customInputs, + children, + assignAllTeamMembers, + }; + // Filter out undefined values + const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => { + if (value !== undefined) { + // @ts-expect-error Element implicitly has any type + acc[key] = value; + } + return acc; + }, {}); + + if (dirtyFieldExists) { + updateMutation.mutate({ ...filteredPayload, id: eventType.id }); + } + }; + + const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState([]); + const slug = formMethods.watch("slug") ?? eventType.slug; + + // Optional prerender all tabs after 300 ms on mount + useEffect(() => { + const timeout = setTimeout(() => { + const Components = [ + EventSetupTab, + EventAvailabilityTab, + EventTeamAssignmentTab, + EventLimitsTab, + EventAdvancedTab, + EventInstantTab, + EventRecurringTab, + EventAppsTab, + EventWorkflowsTab, + EventWebhooksTab, + ]; + + Components.forEach((C) => { + // @ts-expect-error Property 'render' does not exist on type 'ComponentClass + C.render.preload(); + }); + }, 300); + + return () => { + clearTimeout(timeout); + }; + }, []); + return ( + <> + webhook.active).length} + team={team} + availability={availability} + isUpdateMutationLoading={updateMutation.isPending} + formMethods={formMethods} + // disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"} + disableBorder={true} + currentUserMembership={currentUserMembership} + bookerUrl={eventType.bookerUrl} + isUserOrganizationAdmin={props.isUserOrganizationAdmin} + onDelete={onDelete}> +
    { + const { children } = values; + const dirtyValues = getDirtyFields(values); + const dirtyFieldExists = Object.keys(dirtyValues).length !== 0; + const { + periodDates, + periodCountCalendarDays, + beforeEventBuffer, + afterEventBuffer, + seatsPerTimeSlot, + seatsShowAttendees, + seatsShowAvailabilityCount, + bookingLimits, + onlyShowFirstAvailableSlot, + durationLimits, + recurringEvent, + eventTypeColor, + locations, + metadata, + customInputs, + // We don't need to send send these values to the backend + // eslint-disable-next-line @typescript-eslint/no-unused-vars + seatsPerTimeSlotEnabled, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multipleDurationEnabled, + length, + ...input + } = dirtyValues; + + if (length && !Number(length)) throw new Error(t("event_setup_length_error")); + + if (bookingLimits) { + const isValid = validateIntervalLimitOrder(bookingLimits); + if (!isValid) throw new Error(t("event_setup_booking_limits_error")); + } + + if (durationLimits) { + const isValid = validateIntervalLimitOrder(durationLimits); + if (!isValid) throw new Error(t("event_setup_duration_limits_error")); + } + + const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null); + if (layoutError) throw new Error(t(layoutError)); + + if (metadata?.multipleDuration !== undefined) { + if (metadata?.multipleDuration.length < 1) { + throw new Error(t("event_setup_multiple_duration_error")); + } else { + if (length !== undefined) { + if (!length && !metadata?.multipleDuration?.includes(length)) { + //This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined + throw new Error(t("event_setup_multiple_duration_default_error")); + } + } + } + } + + // Prevent two payment apps to be enabled + // Ok to cast type here because this metadata will be updated as the event type metadata + if (checkForMultiplePaymentApps(metadata as z.infer)) + throw new Error(t("event_setup_multiple_payment_apps_error")); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { availability, users, scheduleName, ...rest } = input; + const payload = { + ...rest, + children, + length, + locations, + recurringEvent, + periodStartDate: periodDates?.startDate, + periodEndDate: periodDates?.endDate, + periodCountCalendarDays, + id: eventType.id, + beforeEventBuffer, + afterEventBuffer, + bookingLimits, + onlyShowFirstAvailableSlot, + durationLimits, + eventTypeColor, + seatsPerTimeSlot, + seatsShowAttendees, + seatsShowAvailabilityCount, + metadata, + customInputs, + }; + // Filter out undefined values + const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => { + if (value !== undefined) { + // @ts-expect-error Element implicitly has any type + acc[key] = value; + } + return acc; + }, {}); + + if (dirtyFieldExists) { + updateMutation.mutate({ ...filteredPayload, id: eventType.id, hashedLink: values.hashedLink }); + } + }}> +
    {tabMap[tabName]}
    + +
    + {slugExistsChildrenDialogOpen.length ? ( + { + setSlugExistsChildrenDialogOpen([]); + }} + slug={slug} + onConfirm={(e: { preventDefault: () => void }) => { + e.preventDefault(); + handleSubmit(formMethods.getValues()); + telemetry.event(telemetryEventTypes.slugReplacementAction); + setSlugExistsChildrenDialogOpen([]); + }} + /> + ) : null} + + + ); +}; diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/packages/features/eventtypes/components/EventTypeLayout.tsx similarity index 87% rename from apps/web/components/eventtype/EventTypeSingleLayout.tsx rename to packages/features/eventtypes/components/EventTypeLayout.tsx index ac9d79b56fafc0..77d1a6a5b427a6 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/packages/features/eventtypes/components/EventTypeLayout.tsx @@ -1,26 +1,20 @@ import type { TFunction } from "next-i18next"; -import { Trans } from "next-i18next"; -import { useRouter } from "next/navigation"; -import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useMemo, useState, Suspense } from "react"; import type { UseFormReturn } from "react-hook-form"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed"; import type { FormValues, AvailabilityOption } from "@calcom/features/eventtypes/lib/types"; +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; import Shell from "@calcom/features/shell/Shell"; import { classNames } from "@calcom/lib"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { HttpError } from "@calcom/lib/http-error"; import { SchedulingType } from "@calcom/prisma/enums"; -import { trpc, TRPCClientError } from "@calcom/trpc/react"; -import type { DialogProps, VerticalTabItemProps } from "@calcom/ui"; +import type { VerticalTabItemProps } from "@calcom/ui"; import { Button, ButtonGroup, - ConfirmationDialogContent, - Dialog, DropdownMenuSeparator, Dropdown, DropdownMenuContent, @@ -38,6 +32,8 @@ import { VerticalTabs, } from "@calcom/ui"; +import { DeleteDialog } from "./dialogs/DeleteDialog"; + type Props = { children: React.ReactNode; eventType: EventTypeSetupProps["eventType"]; @@ -113,66 +109,6 @@ function getNavigation({ ] satisfies VerticalTabItemProps[]; } -function DeleteDialog({ - isManagedEvent, - eventTypeId, - open, - onOpenChange, - onDelete, -}: { - isManagedEvent: string; - eventTypeId: number; - onDelete: () => void; -} & Pick) { - const utils = trpc.useUtils(); - const { t } = useLocale(); - const router = useRouter(); - const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({ - onSuccess: async () => { - await utils.viewer.eventTypes.invalidate(); - showToast(t("event_type_deleted_successfully"), "success"); - onDelete(); - router.push("/event-types"); - onOpenChange?.(false); - }, - onError: (err) => { - if (err instanceof HttpError) { - const message = `${err.statusCode}: ${err.message}`; - showToast(message, "error"); - onOpenChange?.(false); - } else if (err instanceof TRPCClientError) { - showToast(err.message, "error"); - } - }, - }); - - return ( - - { - e.preventDefault(); - deleteMutation.mutate({ id: eventTypeId }); - }}> -

    - , ul:

      }}> -
        -
      • Members assigned to this event type will also have their event types deleted.
      • -
      • Anyone who they've shared their link with will no longer be able to book using it.
      • -
      - -

      - -
    - ); -} - function EventTypeSingleLayout({ children, eventType, diff --git a/apps/web/components/eventtype/Locations.tsx b/packages/features/eventtypes/components/Locations.tsx similarity index 97% rename from apps/web/components/eventtype/Locations.tsx rename to packages/features/eventtypes/components/Locations.tsx index 0225cc3fa02855..4b25ddf42a48fc 100644 --- a/apps/web/components/eventtype/Locations.tsx +++ b/packages/features/eventtypes/components/Locations.tsx @@ -2,22 +2,20 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { ErrorMessage } from "@hookform/error-message"; import { Trans } from "next-i18next"; import Link from "next/link"; -import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useEffect, useState } from "react"; import { Controller, useFieldArray } from "react-hook-form"; import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form"; import type { EventLocationType } from "@calcom/app-store/locations"; import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations"; -import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types"; +import type { LocationFormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; +import CheckboxField from "@calcom/features/form/components/CheckboxField"; +import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect"; +import LocationSelect from "@calcom/features/form/components/LocationSelect"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Icon, Input, PhoneInput, Button, showToast } from "@calcom/ui"; -import CheckboxField from "@components/ui/form/CheckboxField"; -import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect"; -import LocationSelect from "@components/ui/form/LocationSelect"; - export type TEventTypeLocation = Pick; export type TLocationOptions = Pick["locationOptions"]; export type TDestinationCalendar = { integration: string } | null; diff --git a/apps/web/components/eventtype/SkeletonLoader.tsx b/packages/features/eventtypes/components/SkeletonLoader.tsx similarity index 100% rename from apps/web/components/eventtype/SkeletonLoader.tsx rename to packages/features/eventtypes/components/SkeletonLoader.tsx diff --git a/apps/web/components/eventtype/AssignmentWarningDialog.tsx b/packages/features/eventtypes/components/dialogs/AssignmentWarningDialog.tsx similarity index 100% rename from apps/web/components/eventtype/AssignmentWarningDialog.tsx rename to packages/features/eventtypes/components/dialogs/AssignmentWarningDialog.tsx diff --git a/packages/features/eventtypes/components/dialogs/DeleteDialog.tsx b/packages/features/eventtypes/components/dialogs/DeleteDialog.tsx new file mode 100644 index 00000000000000..bcc2668510b9af --- /dev/null +++ b/packages/features/eventtypes/components/dialogs/DeleteDialog.tsx @@ -0,0 +1,68 @@ +import { Trans } from "next-i18next"; +import { useRouter } from "next/navigation"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { HttpError } from "@calcom/lib/http-error"; +import { trpc, TRPCClientError } from "@calcom/trpc/react"; +import type { DialogProps } from "@calcom/ui"; +import { ConfirmationDialogContent, Dialog, showToast } from "@calcom/ui"; + +export function DeleteDialog({ + isManagedEvent, + eventTypeId, + open, + onOpenChange, + onDelete, +}: { + isManagedEvent: string; + eventTypeId: number; + onDelete: () => void; +} & Pick) { + const utils = trpc.useUtils(); + const { t } = useLocale(); + const router = useRouter(); + const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({ + onSuccess: async () => { + await utils.viewer.eventTypes.invalidate(); + showToast(t("event_type_deleted_successfully"), "success"); + onDelete(); + router.push("/event-types"); + onOpenChange?.(false); + }, + onError: (err) => { + if (err instanceof HttpError) { + const message = `${err.statusCode}: ${err.message}`; + showToast(message, "error"); + onOpenChange?.(false); + } else if (err instanceof TRPCClientError) { + showToast(err.message, "error"); + } + }, + }); + + return ( + + { + e.preventDefault(); + deleteMutation.mutate({ id: eventTypeId }); + }}> +

    + , ul:

      }}> +
        +
      • Members assigned to this event type will also have their event types deleted.
      • +
      • Anyone who they've shared their link with will no longer be able to book using it.
      • +
      + +

      + +
    + ); +} diff --git a/apps/web/components/eventtype/ManagedEventDialog.tsx b/packages/features/eventtypes/components/dialogs/ManagedEventDialog.tsx similarity index 100% rename from apps/web/components/eventtype/ManagedEventDialog.tsx rename to packages/features/eventtypes/components/dialogs/ManagedEventDialog.tsx diff --git a/apps/web/components/eventtype/CustomEventTypeModal.tsx b/packages/features/eventtypes/components/tabs/advanced/CustomEventTypeModal.tsx similarity index 100% rename from apps/web/components/eventtype/CustomEventTypeModal.tsx rename to packages/features/eventtypes/components/tabs/advanced/CustomEventTypeModal.tsx diff --git a/packages/features/eventtypes/components/tabs/advanced/DisableAllEmailsSetting.tsx b/packages/features/eventtypes/components/tabs/advanced/DisableAllEmailsSetting.tsx new file mode 100644 index 00000000000000..453967a0878a68 --- /dev/null +++ b/packages/features/eventtypes/components/tabs/advanced/DisableAllEmailsSetting.tsx @@ -0,0 +1,82 @@ +import type { TFunction } from "next-i18next"; +import { Trans } from "next-i18next"; +import { useState } from "react"; + +import { + SettingsToggle, + Dialog, + DialogContent, + DialogFooter, + InputField, + DialogClose, + Button, +} from "@calcom/ui"; + +interface DisableEmailsSettingProps { + checked: boolean; + onCheckedChange: (e: boolean) => void; + recipient: "attendees" | "hosts"; + t: TFunction; +} + +export const DisableAllEmailsSetting = ({ + checked, + onCheckedChange, + recipient, + t, +}: DisableEmailsSettingProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [confirmText, setConfirmText] = useState(""); + + const title = + recipient === "attendees" ? t("disable_all_emails_to_attendees") : t("disable_all_emails_to_hosts"); + + return ( +
    + setDialogOpen(e)}> + +

    + + This will disable all emails to {{ recipient }}. This includes booking confirmations, requests, + reschedules and reschedule requests, cancellation emails, and any other emails related to + booking updates. +
    +
    + It is your responsibility to ensure that your {{ recipient }} are aware of any bookings and + changes to their bookings. +
    +

    +

    {t("type_confirm_to_continue")}

    + { + setConfirmText(e.target.value); + }} + /> + + + + +
    +
    + { + checked ? onCheckedChange(!checked) : setDialogOpen(true); + }} + /> +
    + ); +}; diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx similarity index 98% rename from apps/web/components/eventtype/EventAdvancedTab.tsx rename to packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index f66606129d6f4b..88c006574c4c33 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -1,5 +1,4 @@ import dynamic from "next/dynamic"; -import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useEffect, useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import type { z } from "zod"; @@ -13,7 +12,7 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; -import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; import { FormBuilder } from "@calcom/features/form-builder/FormBuilder"; import type { fieldSchema } from "@calcom/features/form-builder/schema"; import type { EditableSchema } from "@calcom/features/form-builder/schema"; @@ -44,12 +43,12 @@ import { ColorPicker, } from "@calcom/ui"; +import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting"; import RequiresConfirmationController from "./RequiresConfirmationController"; -import { DisableAllEmailsSetting } from "./settings/DisableAllEmailsSetting"; type BookingField = z.infer; -const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal")); +const CustomEventTypeModal = dynamic(() => import("./CustomEventTypeModal")); export const EventAdvancedTab = ({ eventType, team }: Pick) => { const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery(); diff --git a/apps/web/components/eventtype/RequiresConfirmationController.tsx b/packages/features/eventtypes/components/tabs/advanced/RequiresConfirmationController.tsx similarity index 99% rename from apps/web/components/eventtype/RequiresConfirmationController.tsx rename to packages/features/eventtypes/components/tabs/advanced/RequiresConfirmationController.tsx index d376176c8a6ab1..644fac98aea4f4 100644 --- a/apps/web/components/eventtype/RequiresConfirmationController.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/RequiresConfirmationController.tsx @@ -1,13 +1,13 @@ import * as RadioGroup from "@radix-ui/react-radio-group"; import type { UnitTypeLongPlural } from "dayjs"; import { Trans } from "next-i18next"; -import type { EventTypeSetup } from "pages/event-types/[type]"; import type { Dispatch, SetStateAction } from "react"; import { useEffect, useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import type z from "zod"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; +import type { EventTypeSetup } from "@calcom/features/eventtypes/lib/types"; import type { FormValues } from "@calcom/features/eventtypes/lib/types"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/apps/web/components/eventtype/AIEventController.tsx b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx similarity index 98% rename from apps/web/components/eventtype/AIEventController.tsx rename to packages/features/eventtypes/components/tabs/ai/AIEventController.tsx index 9d9b78c1bf5d2f..b7cf8df83e299f 100644 --- a/apps/web/components/eventtype/AIEventController.tsx +++ b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx @@ -1,6 +1,5 @@ import * as RadioGroup from "@radix-ui/react-radio-group"; import { useSession } from "next-auth/react"; -import type { EventTypeSetup } from "pages/event-types/[type]"; import React, { useState } from "react"; import { useFormContext, Controller } from "react-hook-form"; import { z } from "zod"; @@ -9,7 +8,7 @@ import { getTemplateFieldsSchema } from "@calcom/features/ee/cal-ai-phone/getTem import { TEMPLATES_FIELDS } from "@calcom/features/ee/cal-ai-phone/template-fields-map"; import type { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; -import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types"; import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/apps/web/components/eventtype/EventAITab.tsx b/packages/features/eventtypes/components/tabs/ai/EventAITab.tsx similarity index 76% rename from apps/web/components/eventtype/EventAITab.tsx rename to packages/features/eventtypes/components/tabs/ai/EventAITab.tsx index df7f1eb92dfc94..01c4a80bb40266 100644 --- a/apps/web/components/eventtype/EventAITab.tsx +++ b/packages/features/eventtypes/components/tabs/ai/EventAITab.tsx @@ -1,4 +1,4 @@ -import type { EventTypeSetupProps } from "pages/event-types/[type]"; +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; import AIEventController from "./AIEventController"; diff --git a/apps/web/components/eventtype/EventAppsTab.tsx b/packages/features/eventtypes/components/tabs/apps/EventAppsTab.tsx similarity index 97% rename from apps/web/components/eventtype/EventAppsTab.tsx rename to packages/features/eventtypes/components/tabs/apps/EventAppsTab.tsx index 9625c791d4c32a..3e3abb16a2a469 100644 --- a/apps/web/components/eventtype/EventAppsTab.tsx +++ b/packages/features/eventtypes/components/tabs/apps/EventAppsTab.tsx @@ -1,19 +1,17 @@ import { Trans } from "next-i18next"; import Link from "next/link"; -import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useFormContext } from "react-hook-form"; import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface"; import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; -import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; +import useAppsData from "@calcom/lib/hooks/useAppsData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Alert, Button, EmptyScreen } from "@calcom/ui"; -import useAppsData from "@lib/hooks/useAppsData"; - export type EventType = Pick["eventType"] & EventTypeAppCardComponentProps["eventType"]; diff --git a/apps/web/components/eventtype/EventTeamTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx similarity index 98% rename from apps/web/components/eventtype/EventTeamTab.tsx rename to packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx index 737582af768003..c8ee253534226f 100644 --- a/apps/web/components/eventtype/EventTeamTab.tsx +++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -1,6 +1,5 @@ import { Trans } from "next-i18next"; import Link from "next/link"; -import type { EventTypeSetupProps, Host } from "pages/event-types/[type]"; import { useEffect, useRef, useState } from "react"; import type { ComponentProps, Dispatch, SetStateAction } from "react"; import { Controller, useFormContext, useWatch } from "react-hook-form"; @@ -12,7 +11,12 @@ import AddMembersWithSwitch, { import AssignAllTeamMembers from "@calcom/features/eventtypes/components/AssignAllTeamMembers"; import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect"; import { sortHosts, weightDescription } from "@calcom/features/eventtypes/components/HostEditDialogs"; -import type { FormValues, TeamMember } from "@calcom/features/eventtypes/lib/types"; +import type { + FormValues, + TeamMember, + EventTypeSetupProps, + Host, +} from "@calcom/features/eventtypes/lib/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { SchedulingType } from "@calcom/prisma/enums"; import { Label, Select, SettingsToggle } from "@calcom/ui"; @@ -388,7 +392,7 @@ const Hosts = ({ ); }; -export const EventTeamTab = ({ +export const EventTeamAssignmentTab = ({ team, teamMembers, eventType, diff --git a/apps/web/components/eventtype/EventAvailabilityTab.tsx b/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx similarity index 97% rename from apps/web/components/eventtype/EventAvailabilityTab.tsx rename to packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx index 7eb97c7af88b74..27afc111a118b8 100644 --- a/apps/web/components/eventtype/EventAvailabilityTab.tsx +++ b/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx @@ -1,12 +1,12 @@ -import type { EventTypeSetup } from "pages/event-types/[type]"; import { useState, memo, useEffect } from "react"; import { Controller, useFormContext } from "react-hook-form"; import type { OptionProps, SingleValueProps } from "react-select"; import { components } from "react-select"; import dayjs from "@calcom/dayjs"; +import { SelectSkeletonLoader } from "@calcom/features/availability/components/SkeletonLoader"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; -import type { AvailabilityOption, FormValues } from "@calcom/features/eventtypes/lib/types"; +import type { AvailabilityOption, FormValues, EventTypeSetup } from "@calcom/features/eventtypes/lib/types"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { weekdayNames } from "@calcom/lib/weekday"; @@ -16,8 +16,6 @@ import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { Badge, Button, Icon, Select, SettingsToggle, SkeletonText } from "@calcom/ui"; -import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader"; - const Option = ({ ...props }: OptionProps) => { const { label, isDefault, isManaged = false } = props.data; const { t } = useLocale(); diff --git a/apps/web/components/eventtype/EventInstantTab.tsx b/packages/features/eventtypes/components/tabs/instant/EventInstantTab.tsx similarity index 85% rename from apps/web/components/eventtype/EventInstantTab.tsx rename to packages/features/eventtypes/components/tabs/instant/EventInstantTab.tsx index 8182a61b2b4219..1aa13d5ab997a8 100644 --- a/apps/web/components/eventtype/EventInstantTab.tsx +++ b/packages/features/eventtypes/components/tabs/instant/EventInstantTab.tsx @@ -1,5 +1,4 @@ -import type { EventTypeSetupProps } from "pages/event-types/[type]"; - +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import InstantEventController from "./InstantEventController"; diff --git a/apps/web/components/eventtype/InstantEventController.tsx b/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx similarity index 98% rename from apps/web/components/eventtype/InstantEventController.tsx rename to packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx index 83ff09498383a9..d498e7f11815b8 100644 --- a/apps/web/components/eventtype/InstantEventController.tsx +++ b/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx @@ -1,12 +1,11 @@ import type { Webhook } from "@prisma/client"; import { useSession } from "next-auth/react"; -import type { EventTypeSetup } from "pages/event-types/[type]"; import { useState } from "react"; import { useFormContext, Controller } from "react-hook-form"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; -import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types"; import { WebhookForm } from "@calcom/features/webhooks/components"; import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm"; import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem"; diff --git a/apps/web/components/eventtype/EventLimitsTab.tsx b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx similarity index 99% rename from apps/web/components/eventtype/EventLimitsTab.tsx rename to packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx index a26fdd5b3ae1b0..83b7e58551a883 100644 --- a/apps/web/components/eventtype/EventLimitsTab.tsx +++ b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx @@ -1,6 +1,5 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import * as RadioGroup from "@radix-ui/react-radio-group"; -import type { EventTypeSetupProps } from "pages/event-types/[type]"; import type { Key } from "react"; import React, { useEffect, useState } from "react"; import type { UseFormRegisterReturn, UseFormReturn } from "react-hook-form"; @@ -9,7 +8,8 @@ import type { SingleValue } from "react-select"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes"; -import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; +import CheckboxField from "@calcom/features/form/components/CheckboxField"; import { classNames } from "@calcom/lib"; import { ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK } from "@calcom/lib/constants"; import type { DurationType } from "@calcom/lib/convertToNewDurationType"; @@ -21,8 +21,6 @@ import { PeriodType } from "@calcom/prisma/enums"; import type { IntervalLimit } from "@calcom/types/Calendar"; import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui"; -import CheckboxField from "@components/ui/form/CheckboxField"; - type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType]; /** diff --git a/apps/web/components/eventtype/EventRecurringTab.tsx b/packages/features/eventtypes/components/tabs/recurring/EventRecurringTab.tsx similarity index 83% rename from apps/web/components/eventtype/EventRecurringTab.tsx rename to packages/features/eventtypes/components/tabs/recurring/EventRecurringTab.tsx index 6f0acde285861d..1aba6b113ac834 100644 --- a/apps/web/components/eventtype/EventRecurringTab.tsx +++ b/packages/features/eventtypes/components/tabs/recurring/EventRecurringTab.tsx @@ -1,5 +1,4 @@ -import type { EventTypeSetupProps } from "pages/event-types/[type]"; - +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import RecurringEventController from "./RecurringEventController"; diff --git a/apps/web/components/eventtype/RecurringEventController.tsx b/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx similarity index 98% rename from apps/web/components/eventtype/RecurringEventController.tsx rename to packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx index f16d40eb19bb50..3822009f254756 100644 --- a/apps/web/components/eventtype/RecurringEventController.tsx +++ b/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx @@ -1,8 +1,8 @@ -import type { EventTypeSetup } from "pages/event-types/[type]"; import { useState } from "react"; import { useFormContext } from "react-hook-form"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; +import type { EventTypeSetup } from "@calcom/features/eventtypes/lib/types"; import type { FormValues } from "@calcom/features/eventtypes/lib/types"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/packages/features/eventtypes/components/tabs/setup/EventSetupTab.tsx similarity index 98% rename from apps/web/components/eventtype/EventSetupTab.tsx rename to packages/features/eventtypes/components/tabs/setup/EventSetupTab.tsx index ebc7b18653a1ad..a7e6adc890ae96 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/packages/features/eventtypes/components/tabs/setup/EventSetupTab.tsx @@ -1,4 +1,3 @@ -import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form"; @@ -6,6 +5,8 @@ import type { MultiValue } from "react-select"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; +import Locations from "@calcom/features/eventtypes/components/Locations"; +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types"; import { WEBSITE_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -14,8 +15,6 @@ import { slugify } from "@calcom/lib/slugify"; import turndown from "@calcom/lib/turndownService"; import { Label, Select, SettingsToggle, Skeleton, TextField, Editor } from "@calcom/ui"; -import Locations from "@components/eventtype/Locations"; - export const EventSetupTab = ( props: Pick< EventTypeSetupProps, diff --git a/apps/web/components/eventtype/EventWebhooksTab.tsx b/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx similarity index 98% rename from apps/web/components/eventtype/EventWebhooksTab.tsx rename to packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx index 095a5020c48a16..869899e7b47027 100644 --- a/apps/web/components/eventtype/EventWebhooksTab.tsx +++ b/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx @@ -1,12 +1,11 @@ import type { Webhook } from "@prisma/client"; import { Trans } from "next-i18next"; import Link from "next/link"; -import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useState } from "react"; import { useFormContext } from "react-hook-form"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; -import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; import { WebhookForm } from "@calcom/features/webhooks/components"; import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm"; import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem"; diff --git a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx b/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx similarity index 96% rename from packages/features/ee/workflows/components/EventWorkflowsTab.tsx rename to packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx index cc648c30a4a601..af2dd8a246235e 100644 --- a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx +++ b/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx @@ -4,7 +4,11 @@ import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; import { Trans } from "react-i18next"; +import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; +import SkeletonLoader from "@calcom/features/ee/workflows/components/SkeletonLoaderEventWorkflowsTab"; +import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage"; +import { getActionIcon } from "@calcom/features/ee/workflows/lib/getActionIcon"; import type { FormValues } from "@calcom/features/eventtypes/lib/types"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -14,11 +18,6 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { Alert, Button, EmptyScreen, Icon, showToast, Switch, Tooltip } from "@calcom/ui"; -import LicenseRequired from "../../common/components/LicenseRequired"; -import { getActionIcon } from "../lib/getActionIcon"; -import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab"; -import type { WorkflowType } from "./WorkflowListPage"; - type PartialWorkflowType = Pick; type ItemProps = { diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 81d79b0b2a7384..59dfe01a2f7074 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -133,3 +133,6 @@ export type FormValues = { }; export type LocationFormValues = Pick; + +export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"]; +export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"]; diff --git a/apps/web/components/ui/form/CheckboxField.tsx b/packages/features/form/components/CheckboxField.tsx similarity index 100% rename from apps/web/components/ui/form/CheckboxField.tsx rename to packages/features/form/components/CheckboxField.tsx diff --git a/apps/web/components/ui/form/LocationSelect.tsx b/packages/features/form/components/LocationSelect.tsx similarity index 100% rename from apps/web/components/ui/form/LocationSelect.tsx rename to packages/features/form/components/LocationSelect.tsx diff --git a/apps/web/components/ui/form/Select.tsx b/packages/features/form/components/Select.tsx similarity index 100% rename from apps/web/components/ui/form/Select.tsx rename to packages/features/form/components/Select.tsx diff --git a/apps/web/lib/checkForEmptyAssignment.ts b/packages/lib/event-types/utils/checkForEmptyAssignment.ts similarity index 87% rename from apps/web/lib/checkForEmptyAssignment.ts rename to packages/lib/event-types/utils/checkForEmptyAssignment.ts index a2e5d5101c8f43..3c23dd9df8739e 100644 --- a/apps/web/lib/checkForEmptyAssignment.ts +++ b/packages/lib/event-types/utils/checkForEmptyAssignment.ts @@ -1,4 +1,7 @@ -import type { EventTypeAssignedUsers, EventTypeHosts } from "~/event-types/views/event-types-single-view"; +import type { + EventTypeAssignedUsers, + EventTypeHosts, +} from "@calcom/features/eventtypes/components/EventType"; // This function checks if EventType requires assignment. // returns true: if EventType requires assignment but there is no assignment yet done by the user. diff --git a/packages/lib/event-types/utils/locationsResolver.ts b/packages/lib/event-types/utils/locationsResolver.ts new file mode 100644 index 00000000000000..d533ab0f1744cf --- /dev/null +++ b/packages/lib/event-types/utils/locationsResolver.ts @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { isValidPhoneNumber } from "libphonenumber-js"; +import type { TFunction } from "next-i18next"; +// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router +import { z } from "zod"; + +import { getEventLocationType } from "@calcom/app-store/locations"; + +export const locationsResolver = (t: TFunction) => { + return z + .array( + z + .object({ + type: z.string(), + address: z.string().optional(), + link: z.string().url().optional(), + phone: z + .string() + .refine((val) => isValidPhoneNumber(val)) + .optional(), + hostPhoneNumber: z + .string() + .refine((val) => isValidPhoneNumber(val)) + .optional(), + displayLocationPublicly: z.boolean().optional(), + credentialId: z.number().optional(), + teamName: z.string().optional(), + }) + .passthrough() + .superRefine((val, ctx) => { + if (val?.link) { + const link = val.link; + const eventLocationType = getEventLocationType(val.type); + if ( + eventLocationType && + !eventLocationType.default && + eventLocationType.linkType === "static" && + eventLocationType.urlRegExp + ) { + const valid = z.string().regex(new RegExp(eventLocationType.urlRegExp)).safeParse(link).success; + + if (!valid) { + const sampleUrl = eventLocationType.organizerInputPlaceholder; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [eventLocationType?.defaultValueVariable ?? "link"], + message: t("invalid_url_error_message", { + label: eventLocationType.label, + sampleUrl: sampleUrl ?? "https://cal.com", + }), + }); + } + return; + } + + const valid = z.string().url().optional().safeParse(link).success; + + if (!valid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [eventLocationType?.defaultValueVariable ?? "link"], + message: `Invalid URL`, + }); + } + } + return; + }) + ) + .optional(); +}; diff --git a/apps/web/lib/hooks/useAppsData.ts b/packages/lib/hooks/useAppsData.ts similarity index 95% rename from apps/web/lib/hooks/useAppsData.ts rename to packages/lib/hooks/useAppsData.ts index b5496a867a0c1f..f6b8a07a25b1ad 100644 --- a/apps/web/lib/hooks/useAppsData.ts +++ b/packages/lib/hooks/useAppsData.ts @@ -1,8 +1,8 @@ -import type { FormValues } from "pages/event-types/[type]"; import { useFormContext } from "react-hook-form"; import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; +import type { FormValues } from "@calcom/features/eventtypes/lib/types"; const useAppsData = () => { const formMethods = useFormContext();