feat: Custom host location (#25916)
* chore: save progress * chore: * feat: add input dialog * fix: type error * feat: mass apply dialog * test: per host location * test: fix test * fix: address Cubic AI review feedback (confidence >= 9/10) - Remove PII (address, phone number) from tracing logs in RegularBookingService.ts - Constrain HostLocation.type to EventLocationType["type"] for compile-time validation Co-Authored-By: unknown <> * fix: translation * refactor: improvements * fix: correct grammar in custom host locations tooltip Change 'custom host locations is enabled' to 'custom host locations are enabled' (plural subject requires plural verb). Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> * refactor: improvements * fix: auth * fix: check * refactor: improvements * fix: address Cubic AI review feedback (confidence >= 9/10) - Add scheduleId to newly created hosts in update.handler.ts to persist host-specific schedules during create operations - Change host location deletion filter from !host.location to host.location === null to only delete when explicitly set to null - Fix static-link per-host locations to use actual link instead of type in locationBodyString for bookingLocationService.ts Co-Authored-By: unknown <> * fix: preserve existing host scheduleId when not explicitly provided Change scheduleId handling for existing hosts from 'host.scheduleId ?? null' to 'host.scheduleId === undefined ? undefined : host.scheduleId' so that when the client doesn't provide a scheduleId, the existing value is preserved instead of being cleared to null. Co-Authored-By: unknown <> * fix; type erro * fix; type erro * fix; type erro * refactor: move repository * refactor: move repository * fix: add singular/plural translations for location_applied_to_hosts Addresses Cubic AI review feedback (confidence 9/10) to fix '1 hosts' rendering as '1 host' by using i18next plural format with _one and _other suffixes. Co-Authored-By: unknown <> * refactor: feedback * fix: type err * fix: use uuid in schema and remove attendee locaiton * fix: type err * fix: type err * fix: validate eventTypeId as integer in massApplyHostLocation schema Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
33 files changed
+2625
-68
No files matched your search
@@ -80,6 +80,7 @@ export const EventMeta = ({
|
||||
| "isDynamic"
|
||||
| "fieldTranslations"
|
||||
| "autoTranslateDescriptionEnabled"
|
||||
| "enablePerHostLocations"
|
||||
> | null;
|
||||
isPending: boolean;
|
||||
isPrivateLink: boolean;
|
||||
|
||||
@@ -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 (
|
||||
<EventMetaBlock key={block}>
|
||||
<AvailableEventLocations locations={event.locations} />
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t(title)} />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-default mb-1 block text-sm font-medium">
|
||||
{t(eventLocationType.messageForOrganizer || "")}
|
||||
</Label>
|
||||
{eventLocationType.organizerInputType === "phone" ? (
|
||||
<PhoneInput
|
||||
value={inputValue}
|
||||
onChange={(val) => setInputValue(val || "")}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={!inputValue}>
|
||||
{t(saveButtonText)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
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<LocationOption | null>(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 (
|
||||
<>
|
||||
<div className="border-subtle flex items-center gap-3 border-b px-3 py-3 last:border-b-0">
|
||||
<Avatar size="sm" imageSrc={avatarUrl} alt={displayName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-emphasis truncate text-sm font-medium">{displayName}</div>
|
||||
{hostData?.email && <div className="text-subtle truncate text-xs">{hostData.email}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentLocation && !hasAppInstalled && (
|
||||
<Badge variant="orange" className="whitespace-nowrap">
|
||||
<Icon name="triangle-alert" className="mr-1 h-3 w-3" />
|
||||
{t("app_not_installed")}
|
||||
</Badge>
|
||||
)}
|
||||
<LocationSelect
|
||||
placeholder={t("select_location")}
|
||||
options={locationOptions}
|
||||
value={selectedOption}
|
||||
isSearchable={false}
|
||||
className="w-72 text-sm"
|
||||
menuPlacement="auto"
|
||||
menuPortalTarget={typeof document !== "undefined" ? document.body : null}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }) as CSSObjectWithLabel,
|
||||
}}
|
||||
onChange={handleLocationSelect}
|
||||
/>
|
||||
{hasOrganizerInput && currentLocation && (
|
||||
<Button color="minimal" type="button" StartIcon="pencil" onClick={handleEditClick} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LocationInputDialog
|
||||
isOpen={isDialogOpen}
|
||||
onClose={handleDialogClose}
|
||||
locationOption={pendingLocationOption}
|
||||
onSave={handleDialogSave}
|
||||
title="set_location"
|
||||
saveButtonText="save"
|
||||
initialValue={
|
||||
pendingLocationOption && pendingLocationOption.value !== currentLocation?.type
|
||||
? ""
|
||||
: currentLocationValue
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type AllLocationOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const getAllLocationOptions = (): AllLocationOption[] => {
|
||||
const options: AllLocationOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
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<string | null>(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 }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && <img src={icon} alt="" className={`h-4 w-4 ${invertLogoOnDark(icon)}`} />}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("apply_location_to_all_hosts")} />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1">{t("select_location_type")}</Label>
|
||||
<Select
|
||||
value={selectValue}
|
||||
onChange={(option) => {
|
||||
setSelectedType(option?.value || null);
|
||||
setInputValue("");
|
||||
}}
|
||||
options={selectOptions}
|
||||
className="w-full"
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.Option>
|
||||
),
|
||||
SingleValue: (props) => (
|
||||
<components.SingleValue {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.SingleValue>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{needsInput && eventLocationType && (
|
||||
<LocationInputField
|
||||
eventLocationType={eventLocationType}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
/>
|
||||
)}
|
||||
<Alert severity="info" title={t("mass_apply_fallback_explanation")} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={!selectedType || (needsInput && !inputValue) || isApplying}
|
||||
loading={isApplying}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
type LocationInputFieldProps = {
|
||||
eventLocationType: ReturnType<typeof getEventLocationType>;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
};
|
||||
|
||||
const LocationInputField = ({ eventLocationType, inputValue, setInputValue }: LocationInputFieldProps) => {
|
||||
const { t } = useLocale();
|
||||
if (!eventLocationType) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1">{t(eventLocationType.messageForOrganizer || "")}</Label>
|
||||
{eventLocationType.organizerInputType === "phone" ? (
|
||||
<PhoneInput
|
||||
value={inputValue}
|
||||
onChange={(val) => setInputValue(val || "")}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useFetchMoreOnScroll = (
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
hasNextPage: boolean | undefined,
|
||||
isFetchingNextPage: boolean,
|
||||
fetchNextPage: () => void
|
||||
) => {
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const { scrollHeight, scrollTop, clientHeight } = container;
|
||||
if (scrollHeight - scrollTop - clientHeight < 100) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [containerRef, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll, containerRef]);
|
||||
};
|
||||
|
||||
const mergeLocationOptionsWithHostApps = (
|
||||
locationOptions: TLocationOptions,
|
||||
hostsWithApps: HostWithLocationOptions[],
|
||||
t: (key: string) => string
|
||||
): TLocationOptions => {
|
||||
if (hostsWithApps.length === 0) return locationOptions;
|
||||
|
||||
const existingValues = new Set<string>();
|
||||
locationOptions.forEach((group) => {
|
||||
group.options.forEach((opt) => existingValues.add(opt.value));
|
||||
});
|
||||
|
||||
const hostAppsOptions: TLocationOptions[number]["options"] = [];
|
||||
hostsWithApps.forEach((host) => {
|
||||
host.installedApps.forEach((app) => {
|
||||
if (app.locationOption && !existingValues.has(app.locationOption.value)) {
|
||||
existingValues.add(app.locationOption.value);
|
||||
hostAppsOptions.push({
|
||||
value: app.locationOption.value,
|
||||
label: app.locationOption.label,
|
||||
icon: app.locationOption.icon,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (hostAppsOptions.length === 0) return locationOptions;
|
||||
|
||||
const conferencingGroup = locationOptions.find(
|
||||
(g) => g.label.toLowerCase().includes("conferencing") || g.label.toLowerCase().includes("video")
|
||||
);
|
||||
|
||||
if (conferencingGroup) {
|
||||
return locationOptions.map((group) => {
|
||||
if (group === conferencingGroup) {
|
||||
return { ...group, options: [...group.options, ...hostAppsOptions] };
|
||||
}
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
return [...locationOptions, { label: t("conferencing"), options: hostAppsOptions }];
|
||||
};
|
||||
|
||||
type HostListProps = {
|
||||
hosts: Host[];
|
||||
hostDataMap: Map<number, HostWithLocationOptions>;
|
||||
locationOptions: TLocationOptions;
|
||||
onLocationChange: (userId: number, location: HostLocation | null) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
isLoading: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onOpenMassApply: () => void;
|
||||
};
|
||||
|
||||
const HostList = ({
|
||||
hosts,
|
||||
hostDataMap,
|
||||
locationOptions,
|
||||
onLocationChange,
|
||||
containerRef,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
onOpenMassApply,
|
||||
}: HostListProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton as={Label} loadingClassName="w-24" className="mt-auto mb-0">
|
||||
{t("host_locations")}
|
||||
</Skeleton>
|
||||
<Button type="button" color="secondary" onClick={onOpenMassApply}>
|
||||
{t("set_location_for_all_hosts")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="border-subtle max-h-96 overflow-y-auto rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Icon name="loader" className="text-subtle h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hosts.map((host) => (
|
||||
<HostLocationRow
|
||||
key={host.userId}
|
||||
host={host}
|
||||
hostData={hostDataMap.get(host.userId)}
|
||||
locationOptions={locationOptions}
|
||||
onLocationChange={onLocationChange}
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Icon name="loader" className="text-subtle h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-subtle text-xs">{t("host_locations_fallback_description")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useHostLocationsData = (eventTypeId: number, enabled: boolean, locationOptions: TLocationOptions) => {
|
||||
const { t } = useLocale();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
trpc.viewer.eventTypes.getHostsWithLocationOptions.useInfiniteQuery(
|
||||
{ eventTypeId, limit: 10 },
|
||||
{ enabled: enabled && eventTypeId > 0, getNextPageParam: (lastPage) => lastPage.nextCursor }
|
||||
);
|
||||
|
||||
const hostsWithApps = useMemo(() => data?.pages.flatMap((page) => page.hosts) ?? [], [data]);
|
||||
const hostDataMap = useMemo(() => new Map(hostsWithApps.map((h) => [h.userId, h])), [hostsWithApps]);
|
||||
const mergedLocationOptions = useMemo(() => {
|
||||
const merged = mergeLocationOptionsWithHostApps(locationOptions, hostsWithApps, t);
|
||||
return filterOutBookerInputLocations(merged);
|
||||
}, [locationOptions, hostsWithApps, t]);
|
||||
|
||||
useFetchMoreOnScroll(containerRef, hasNextPage, isFetchingNextPage, fetchNextPage);
|
||||
|
||||
return { hostDataMap, mergedLocationOptions, containerRef, isLoading, isFetchingNextPage };
|
||||
};
|
||||
|
||||
const useHostLocationHandlers = (
|
||||
formMethods: ReturnType<typeof useFormContext<FormValues>>,
|
||||
hosts: Host[]
|
||||
) => {
|
||||
const handleToggle = (checked: boolean) => {
|
||||
formMethods.setValue("enablePerHostLocations", checked, { shouldDirty: true });
|
||||
if (!checked) {
|
||||
formMethods.setValue(
|
||||
"hosts",
|
||||
hosts.map((host) => ({ ...host, location: null })),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocationChange = (userId: number, location: HostLocation | null) => {
|
||||
formMethods.setValue(
|
||||
"hosts",
|
||||
hosts.map((h) => (h.userId === userId ? { ...h, location } : h)),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
};
|
||||
|
||||
return { handleToggle, handleLocationChange };
|
||||
};
|
||||
|
||||
const useMassApplyMutation = (
|
||||
eventTypeId: number,
|
||||
formMethods: ReturnType<typeof useFormContext<FormValues>>,
|
||||
hosts: Host[],
|
||||
onSuccess: () => void
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const mutation = trpc.viewer.eventTypes.massApplyHostLocation.useMutation({
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
|
||||
const handleMassApply = (locationType: string, inputValue?: string) => {
|
||||
const evtLocType = getEventLocationType(locationType);
|
||||
const link = evtLocType?.defaultValueVariable === "link" ? inputValue : undefined;
|
||||
const address = evtLocType?.defaultValueVariable === "address" ? inputValue : undefined;
|
||||
const phoneNumber = evtLocType?.organizerInputType === "phone" ? inputValue : undefined;
|
||||
|
||||
mutation.mutate(
|
||||
{ eventTypeId, locationType, link, address, phoneNumber },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
const updatedHosts = hosts.map((host) => ({
|
||||
...host,
|
||||
location: {
|
||||
userId: host.userId,
|
||||
eventTypeId,
|
||||
type: locationType,
|
||||
credentialId: null,
|
||||
link: link ?? null,
|
||||
address: address ?? null,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
},
|
||||
}));
|
||||
formMethods.setValue("hosts", updatedHosts, { shouldDirty: true });
|
||||
|
||||
showToast(t("location_applied_to_hosts", { count: result.updatedCount }), "success");
|
||||
onSuccess();
|
||||
utils.viewer.eventTypes.getHostsWithLocationOptions.invalidate({ eventTypeId });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return { handleMassApply, isPending: mutation.isPending };
|
||||
};
|
||||
|
||||
const UpgradeBadge = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<a href="/enterprise" className="hover:underline">
|
||||
<Badge variant="gray">{t("upgrade")}</Badge>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsProps) => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [isMassApplyDialogOpen, setIsMassApplyDialogOpen] = useState(false);
|
||||
|
||||
const isOrg = !!session.data?.user?.org?.id;
|
||||
const enablePerHostLocations = formMethods.watch("enablePerHostLocations");
|
||||
const hosts = formMethods.watch("hosts");
|
||||
|
||||
const { hostDataMap, mergedLocationOptions, containerRef, isLoading, isFetchingNextPage } =
|
||||
useHostLocationsData(eventTypeId, enablePerHostLocations, locationOptions);
|
||||
const { handleToggle, handleLocationChange } = useHostLocationHandlers(formMethods, hosts);
|
||||
const { handleMassApply, isPending } = useMassApplyMutation(eventTypeId, formMethods, hosts, () =>
|
||||
setIsMassApplyDialogOpen(false)
|
||||
);
|
||||
|
||||
if (hosts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
<div className="space-y-4">
|
||||
<SettingsToggle
|
||||
title={t("enable_custom_host_locations")}
|
||||
description={t("enable_custom_host_locations_description")}
|
||||
checked={enablePerHostLocations}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!isOrg}
|
||||
Badge={!isOrg ? <UpgradeBadge /> : undefined}
|
||||
/>
|
||||
{enablePerHostLocations && (
|
||||
<HostList
|
||||
hosts={hosts}
|
||||
hostDataMap={hostDataMap}
|
||||
locationOptions={mergedLocationOptions}
|
||||
onLocationChange={handleLocationChange}
|
||||
containerRef={containerRef}
|
||||
isLoading={isLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onOpenMassApply={() => setIsMassApplyDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MassApplyLocationDialog
|
||||
isOpen={isMassApplyDialogOpen}
|
||||
onClose={() => setIsMassApplyDialogOpen(false)}
|
||||
onApply={handleMassApply}
|
||||
isApplying={isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostLocations;
|
||||
@@ -13,10 +13,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { CheckboxField, Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form";
|
||||
import { Skeleton } from "@calcom/ui/components/skeleton";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import HostLocations from "@calcom/web/modules/event-types/components/locations/HostLocations";
|
||||
import Locations from "@calcom/web/modules/event-types/components/locations/Locations";
|
||||
import { useState } from "react";
|
||||
import type { Control, FormState, UseFormGetValues, UseFormSetValue } from "react-hook-form";
|
||||
@@ -73,6 +77,7 @@ export const EventSetupTab = (
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
const enablePerHostLocations = formMethods.watch("enablePerHostLocations");
|
||||
|
||||
const multipleDurationOptions = [
|
||||
5, 10, 15, 20, 25, 30, 40, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
|
||||
@@ -349,43 +354,61 @@ export const EventSetupTab = (
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-lg border border-subtle p-6",
|
||||
customClassNames?.locationSection?.container
|
||||
)}>
|
||||
<div>
|
||||
<Skeleton
|
||||
as={Label}
|
||||
loadingClassName="w-16"
|
||||
htmlFor="locations"
|
||||
className={customClassNames?.locationSection?.label}>
|
||||
{t("location")}
|
||||
{/*improve shouldLockIndicator function to also accept eventType and then conditionally render
|
||||
based on Managed Event type or not.*/}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => (
|
||||
<Locations
|
||||
showAppStoreLink={true}
|
||||
isChildrenManagedEventType={isChildrenManagedEventType}
|
||||
isManagedEventType={isManagedEventType}
|
||||
disableLocationProp={shouldLockDisableProps("locations").disabled}
|
||||
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||
{...props}
|
||||
customClassNames={customClassNames?.locationSection}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={t("locations_disabled_per_host_enabled")}
|
||||
side="top"
|
||||
open={
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN && enablePerHostLocations
|
||||
? undefined
|
||||
: false
|
||||
}>
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-lg border border-subtle p-6",
|
||||
customClassNames?.locationSection?.container,
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
||||
enablePerHostLocations &&
|
||||
"cursor-not-allowed opacity-60"
|
||||
)}>
|
||||
<div>
|
||||
<Skeleton
|
||||
as={Label}
|
||||
loadingClassName="w-16"
|
||||
htmlFor="locations"
|
||||
className={customClassNames?.locationSection?.label}>
|
||||
{t("location")}
|
||||
{/*improve shouldLockIndicator function to also accept eventType and then conditionally render
|
||||
based on Managed Event type or not.*/}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => (
|
||||
<Locations
|
||||
showAppStoreLink={true}
|
||||
isChildrenManagedEventType={isChildrenManagedEventType}
|
||||
isManagedEventType={isManagedEventType}
|
||||
disableLocationProp={
|
||||
shouldLockDisableProps("locations").disabled ||
|
||||
(eventType.schedulingType === SchedulingType.ROUND_ROBIN && enablePerHostLocations)
|
||||
}
|
||||
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||
{...props}
|
||||
customClassNames={customClassNames?.locationSection}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||
<HostLocations eventTypeId={eventType.id} locationOptions={props.locationOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"apply_to_all": "Apply to all",
|
||||
"identity_provider": "Identity provider",
|
||||
"trial_days_left": "You have $t(day, {\"count\": {{days}} }) left on your pro trial",
|
||||
"day_one": "{{count}} day",
|
||||
@@ -4369,6 +4370,20 @@
|
||||
"saved": "Saved",
|
||||
"booking_history": "Booking history",
|
||||
"booking_history_description": "View the history of actions performed on this booking",
|
||||
"enable_custom_host_locations": "Enable custom host locations",
|
||||
"enable_custom_host_locations_description": "Allow each host to have their own meeting location",
|
||||
"locations_disabled_per_host_enabled": "Locations are controlled per-host when custom host locations option is enabled",
|
||||
"host_locations": "Configure location per host",
|
||||
"select_location": "Select location",
|
||||
"host_locations_fallback_description": "When a host doesn't have the selected app installed, Cal Video will be used as a fallback",
|
||||
"select_option_hosts": "Apply to all hosts",
|
||||
"set_location_for_all_hosts": "Set location for all hosts",
|
||||
"apply_location_to_all_hosts": "Apply location to all hosts",
|
||||
"select_location_type": "Select location type",
|
||||
"mass_apply_fallback_title": "Fallback behavior",
|
||||
"mass_apply_fallback_explanation": "Hosts without this app installed will use Cal Video. If they install the app later, their preferred location will be used automatically.",
|
||||
"location_applied_to_hosts_one": "Location applied to {{count}} host",
|
||||
"location_applied_to_hosts_other": "Location applied to {{count}} hosts",
|
||||
"booking_audit_action": {
|
||||
"created": "Booked with {{host}}",
|
||||
"created_with_seat": "Seat Booked with {{host}}",
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -169,6 +170,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -230,6 +232,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -356,6 +359,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -383,6 +387,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -446,6 +451,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
assignRRMembersUsingSegment,
|
||||
rrSegmentQueryValue,
|
||||
useEventLevelSelectedCalendars,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -520,6 +526,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -554,6 +561,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -579,6 +587,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
instantMeetingScheduleId: null,
|
||||
assignRRMembersUsingSegment: false,
|
||||
includeNoShowInRRCalculation: false,
|
||||
enablePerHostLocations,
|
||||
...evType,
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user