diff --git a/apps/web/modules/bookings/components/EventMeta.tsx b/apps/web/modules/bookings/components/EventMeta.tsx index a838cf72b06aa8..ed67cc3838271e 100644 --- a/apps/web/modules/bookings/components/EventMeta.tsx +++ b/apps/web/modules/bookings/components/EventMeta.tsx @@ -80,6 +80,7 @@ export const EventMeta = ({ | "isDynamic" | "fieldTranslations" | "autoTranslateDescriptionEnabled" + | "enablePerHostLocations" > | null; isPending: boolean; isPrivateLink: boolean; diff --git a/apps/web/modules/bookings/components/event-meta/Details.tsx b/apps/web/modules/bookings/components/event-meta/Details.tsx index 693d2962562c06..03fdbea0db1f66 100644 --- a/apps/web/modules/bookings/components/event-meta/Details.tsx +++ b/apps/web/modules/bookings/components/event-meta/Details.tsx @@ -21,6 +21,7 @@ type EventDetailsPropsBase = { | "currency" | "price" | "locations" + | "enablePerHostLocations" | "requiresConfirmation" | "recurringEvent" | "length" @@ -149,7 +150,7 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even ); case EventDetailBlocks.LOCATION: - if (!event?.locations?.length || isInstantMeeting) return null; + if (!event?.locations?.length || isInstantMeeting || event.enablePerHostLocations) return null; return ( diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx new file mode 100644 index 00000000000000..a1c7ab7e756e24 --- /dev/null +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -0,0 +1,841 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import type { CSSObjectWithLabel } from "react-select"; +import { components } from "react-select"; + +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { + defaultLocations, + getAppSlugFromLocationType, + getEventLocationType, + isCalVideoLocation, + isStaticLocationType, +} from "@calcom/app-store/locations"; +import { getAppFromSlug } from "@calcom/app-store/utils"; +import PhoneInput from "@calcom/features/components/phone-input"; +import invertLogoOnDark from "@calcom/lib/invertLogoOnDark"; +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 { trpc } from "@calcom/trpc/react"; +import { Alert } from "@calcom/ui/components/alert"; +import { Avatar } from "@calcom/ui/components/avatar"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; +import { Label, TextField, Select, SettingsToggle } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; +import { Skeleton } from "@calcom/ui/components/skeleton"; +import { showToast } from "@calcom/ui/components/toast"; + +import type { FormValues, Host, HostLocation } from "@calcom/features/eventtypes/lib/types"; +import type { TLocationOptions } from "./Locations"; + +type HostWithLocationOptions = { + userId: number; + name: string | null; + email: string; + avatarUrl: string | null; + defaultConferencingApp: { + appSlug?: string; + appLink?: string; + } | null; + location: { + id: string; + type: string; + credentialId: number | null; + link: string | null; + address: string | null; + phoneNumber: string | null; + } | null; + installedApps: { + appId: string | null; + credentialId: number; + type: string; + locationOption?: { + value: string; + label: string; + icon?: string; + }; + }[]; +}; + +type HostLocationsProps = { + eventTypeId: number; + locationOptions: TLocationOptions; +}; + +const getLocationFromOptions = ( + locationType: string, + locationOptions: TLocationOptions +): LocationOption | undefined => { + for (const group of locationOptions) { + const option = group.options.find((opt) => opt.value === locationType); + if (option) return option; + } + return undefined; +}; + +const filterOutBookerInputLocations = (options: TLocationOptions): TLocationOptions => { + return options + .map((group) => ({ + ...group, + options: group.options.filter((opt) => { + const locationType = getEventLocationType(opt.value); + return !locationType?.attendeeInputType; + }), + })) + .filter((group) => group.options.length > 0); +}; + +type LocationInputDialogProps = { + isOpen: boolean; + onClose: () => void; + locationOption: LocationOption | null; + onSave: (inputValue: string) => void; + title: string; + saveButtonText: string; + initialValue?: string; +}; + +const LocationInputDialog = ({ + isOpen, + onClose, + locationOption, + onSave, + title, + saveButtonText, + initialValue = "", +}: LocationInputDialogProps) => { + const { t } = useLocale(); + const eventLocationType = locationOption ? getEventLocationType(locationOption.value) : null; + const [inputValue, setInputValue] = useState(initialValue); + + useEffect(() => { + if (isOpen) { + setInputValue(initialValue); + } + }, [isOpen, initialValue]); + + const handleSave = () => { + onSave(inputValue); + setInputValue(""); + onClose(); + }; + + const handleClose = () => { + setInputValue(""); + onClose(); + }; + + if (!eventLocationType) return null; + + return ( + !open && handleClose()}> + + +
+
+ + {eventLocationType.organizerInputType === "phone" ? ( + setInputValue(val || "")} + placeholder={t(eventLocationType.organizerInputPlaceholder || "")} + /> + ) : ( + setInputValue(e.target.value)} + placeholder={t(eventLocationType.organizerInputPlaceholder || "")} + type="text" + /> + )} +
+
+ + + + +
+
+ ); +}; + +const HostLocationRow = ({ + host, + hostData, + locationOptions, + onLocationChange, +}: { + host: Host; + hostData?: HostWithLocationOptions; + locationOptions: TLocationOptions; + onLocationChange: (userId: number, location: HostLocation | null) => void; +}) => { + const { t } = useLocale(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [pendingLocationOption, setPendingLocationOption] = useState(null); + + const currentLocation = host.location; + + const selectedOption = useMemo(() => { + if (currentLocation) { + return getLocationFromOptions(currentLocation.type, locationOptions); + } + + if (hostData?.defaultConferencingApp?.appSlug) { + const locationType = getAppFromSlug(hostData.defaultConferencingApp.appSlug)?.appData?.location?.type; + if (locationType) { + return getLocationFromOptions(locationType, locationOptions); + } + } + + return getLocationFromOptions("integrations:daily", locationOptions); + }, [currentLocation, hostData, locationOptions]); + + const hasAppInstalled = useMemo(() => { + if (!currentLocation?.type) return true; + if (isStaticLocationType(currentLocation.type)) return true; + if (isCalVideoLocation(currentLocation.type)) return true; + if (!hostData) return true; + + const appSlug = getAppSlugFromLocationType(currentLocation.type); + if (!appSlug) return true; + + return hostData.installedApps.some((app) => app.appId === appSlug || app.type === currentLocation.type); + }, [currentLocation, hostData]); + + const displayName = hostData?.name || `${t("user")} ${host.userId}`; + const avatarUrl = hostData?.avatarUrl || undefined; + + const currentLocationEventType = currentLocation ? getEventLocationType(currentLocation.type) : null; + const hasOrganizerInput = !!currentLocationEventType?.organizerInputType; + + const currentLocationValue = useMemo(() => { + if (!currentLocation || !currentLocationEventType) return ""; + if (currentLocationEventType.defaultValueVariable === "link") { + return currentLocation.link || ""; + } + if (currentLocationEventType.defaultValueVariable === "address") { + return currentLocation.address || ""; + } + if (currentLocationEventType.organizerInputType === "phone") { + return currentLocation.phoneNumber || ""; + } + return ""; + }, [currentLocation, currentLocationEventType]); + + const handleEditClick = () => { + if (selectedOption) { + setPendingLocationOption(selectedOption); + setIsDialogOpen(true); + } + }; + + const handleLocationSelect = (option: LocationOption | null) => { + if (!option) { + onLocationChange(host.userId, null); + return; + } + + const eventLocationType = getEventLocationType(option.value); + + if (eventLocationType?.organizerInputType) { + setPendingLocationOption(option); + setIsDialogOpen(true); + return; + } + + const credential = hostData?.installedApps.find( + (app) => app.appId === getAppSlugFromLocationType(option.value) || app.type === option.value + ); + onLocationChange(host.userId, { + userId: host.userId, + eventTypeId: 0, + type: option.value, + credentialId: credential?.credentialId ?? null, + }); + }; + + const handleDialogSave = (inputValue: string) => { + if (!pendingLocationOption) return; + + const eventLocationType = getEventLocationType(pendingLocationOption.value); + const credential = hostData?.installedApps.find( + (app) => + app.appId === getAppSlugFromLocationType(pendingLocationOption.value) || + app.type === pendingLocationOption.value + ); + + const location: HostLocation = { + userId: host.userId, + eventTypeId: 0, + type: pendingLocationOption.value, + credentialId: credential?.credentialId ?? null, + }; + + if (eventLocationType?.organizerInputType === "text") { + if (eventLocationType.defaultValueVariable === "link") { + location.link = inputValue; + } else if (eventLocationType.defaultValueVariable === "address") { + location.address = inputValue; + } + } else if (eventLocationType?.organizerInputType === "phone") { + location.phoneNumber = inputValue; + } + + onLocationChange(host.userId, location); + setPendingLocationOption(null); + }; + + const handleDialogClose = () => { + setIsDialogOpen(false); + setPendingLocationOption(null); + }; + + return ( + <> +
+ +
+
{displayName}
+ {hostData?.email &&
{hostData.email}
} +
+
+ {currentLocation && !hasAppInstalled && ( + + + {t("app_not_installed")} + + )} + ({ ...base, zIndex: 9999 }) as CSSObjectWithLabel, + }} + onChange={handleLocationSelect} + /> + {hasOrganizerInput && currentLocation && ( +
+
+ + + ); +}; + +type AllLocationOption = { + value: string; + label: string; + icon?: string; +}; + +const getAllLocationOptions = (): AllLocationOption[] => { + const options: AllLocationOption[] = []; + const seenValues = new Set(); + + defaultLocations.forEach((loc) => { + if (!seenValues.has(loc.type)) { + seenValues.add(loc.type); + options.push({ + value: loc.type, + label: loc.label, + icon: loc.iconUrl, + }); + } + }); + + Object.values(appStoreMetadata).forEach((app) => { + const locationData = app.appData?.location; + if (locationData && !seenValues.has(locationData.type)) { + seenValues.add(locationData.type); + options.push({ + value: locationData.type, + label: locationData.label || app.name, + icon: app.logo, + }); + } + }); + + return options; +}; + +type MassApplyLocationDialogProps = { + isOpen: boolean; + onClose: () => void; + onApply: (locationType: string, inputValue?: string) => void; + isApplying: boolean; +}; + +const useMassApplyDialogState = (isOpen: boolean) => { + const [selectedType, setSelectedType] = useState(null); + const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + if (!isOpen) { + setSelectedType(null); + setInputValue(""); + } + }, [isOpen]); + + const reset = () => { + setSelectedType(null); + setInputValue(""); + }; + + return { selectedType, setSelectedType, inputValue, setInputValue, reset }; +}; + +const MassApplyLocationDialog = ({ isOpen, onClose, onApply, isApplying }: MassApplyLocationDialogProps) => { + const { t } = useLocale(); + const { selectedType, setSelectedType, inputValue, setInputValue, reset } = useMassApplyDialogState(isOpen); + const allLocationOptions = useMemo(() => { + return getAllLocationOptions().filter((opt) => { + const locationType = getEventLocationType(opt.value); + return !locationType?.attendeeInputType; + }); + }, []); + + const selectedOption = allLocationOptions.find((o) => o.value === selectedType); + const eventLocationType = selectedType ? getEventLocationType(selectedType) : null; + const needsInput = eventLocationType?.organizerInputType; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleApply = () => { + if (!selectedType) return; + onApply(selectedType, inputValue || undefined); + }; + + const getTranslatedLabel = (opt: AllLocationOption) => { + const translated = t(opt.label); + return translated !== opt.label ? translated : opt.label; + }; + + const selectOptions = allLocationOptions.map((opt) => ({ + value: opt.value, + label: getTranslatedLabel(opt), + icon: opt.icon, + })); + const selectedTranslatedLabel = selectedOption ? getTranslatedLabel(selectedOption) : null; + const selectValue = selectedOption + ? { value: selectedOption.value, label: selectedTranslatedLabel, icon: selectedOption.icon } + : null; + + const OptionWithIcon = ({ icon, label }: { icon?: string; label: string | null }) => ( +
+ {icon && } + {label} +
+ ); + + return ( + !open && handleClose()}> + + +
+
+ +