Files
cal.diy/companion/utils/locationHelpers.ts
T
Everton Resende a71d62dc33 fix(ui): change phone booking display from Organizer Phone Number to Phone Call (#27636)
* fix(ui): change phone booking display from Organizer Phone Number to Phone Call

The label \"Organizer Phone Number\" was confusing for attendees on the
booking page, as it exposed internal terminology. Replaced with the
more user-friendly \"Phone Call\" label across the booking UI, companion
app, and test assertions.

Closes #13010

* fix: remove duplicate phone_call i18n key

The phone_call key already existed in en/common.json (line 1685,
Cal.ai Voice Agent section). Removed the duplicate entry we added.

Found by ai-codex review.
2026-02-06 15:28:38 +00:00

513 lines
14 KiB
TypeScript

/**
* Helper functions for handling event type locations
* Provides utilities for converting between API format and UI format
*/
import type {
ApiLocation,
ApiLocationInput,
LocationItem,
LocationOption,
LocationOptionGroup,
} from "@/types/locations";
import { DefaultLocationType, defaultLocations } from "./defaultLocations";
import { formatAppIdToDisplayName } from "./formatters";
import { getAppIconUrl } from "./getAppIconUrl";
// Re-export formatAppIdToDisplayName for backward compatibility
export { formatAppIdToDisplayName } from "./formatters";
/**
* Generate a unique ID for a location item
*/
export function generateLocationId(): string {
return `loc_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Map an API location response to a UI LocationItem
*/
export function mapApiLocationToItem(apiLocation: ApiLocation): LocationItem {
const id = generateLocationId();
// Handle integration type (conferencing apps)
if (apiLocation.type === "integration" && apiLocation.integration) {
const iconUrl = getAppIconUrl("", apiLocation.integration);
return {
id,
type: "integration",
integration: apiLocation.integration,
displayName: formatAppIdToDisplayName(apiLocation.integration),
iconUrl,
public: apiLocation.public,
};
}
// Handle address type
if (apiLocation.type === "address") {
return {
id,
type: "address",
address: apiLocation.address || "",
displayName: "In Person (Organizer Address)",
iconUrl: "https://app.cal.com/map-pin-dark.svg",
public: apiLocation.public,
};
}
// Handle attendeeAddress type
if (apiLocation.type === "attendeeAddress") {
return {
id,
type: "attendeeAddress",
displayName: "In Person (Attendee Address)",
iconUrl: "https://app.cal.com/map-pin-dark.svg",
};
}
// Handle link type
if (apiLocation.type === "link") {
return {
id,
type: "link",
link: apiLocation.link || "",
displayName: "Link Meeting",
iconUrl: "https://app.cal.com/link.svg",
public: apiLocation.public,
};
}
// Handle phone type (organizer phone)
if (apiLocation.type === "phone") {
return {
id,
type: "phone",
phone: apiLocation.phone || "",
displayName: "Phone Call",
iconUrl: "https://app.cal.com/phone.svg",
public: apiLocation.public,
};
}
// Handle attendeePhone type
if (apiLocation.type === "attendeePhone") {
return {
id,
type: "attendeePhone",
displayName: "Attendee Phone Number",
iconUrl: "https://app.cal.com/phone.svg",
};
}
// Handle attendeeDefined type
if (apiLocation.type === "attendeeDefined") {
return {
id,
type: "attendeeDefined",
displayName: "Custom Attendee Location",
iconUrl: "https://app.cal.com/message-pin.svg",
};
}
// Fallback for unknown types
return {
id,
type: apiLocation.type as LocationItem["type"],
displayName: apiLocation.type,
iconUrl: null,
};
}
/**
* Map a UI LocationItem to API location format for saving
*/
export function mapItemToApiLocation(item: LocationItem): ApiLocationInput {
// Handle integration type
if (item.type === "integration" && item.integration) {
return {
type: "integration",
integration: item.integration,
};
}
// Handle address type
if (item.type === "address") {
return {
type: "address",
address: item.address || "",
public: item.public ?? true,
};
}
// Handle attendeeAddress type
if (item.type === "attendeeAddress") {
return {
type: "attendeeAddress",
};
}
// Handle link type
if (item.type === "link") {
return {
type: "link",
link: item.link || "",
public: item.public ?? true,
};
}
// Handle phone type (organizer phone)
if (item.type === "phone") {
return {
type: "phone",
phone: item.phone || "",
public: item.public ?? true,
};
}
// Handle attendeePhone type
if (item.type === "attendeePhone") {
return {
type: "attendeePhone",
};
}
// Handle attendeeDefined type
if (item.type === "attendeeDefined") {
return {
type: "attendeeDefined",
};
}
// Fallback
return {
type: item.type,
};
}
/**
* Get the display name for a location type
*/
export function getLocationDisplayName(locationType: string, integration?: string): string {
// Handle integration type
if (locationType === "integration" && integration) {
return formatAppIdToDisplayName(integration);
}
// Check default locations
const typeToDisplayName: Record<string, string> = {
address: "In Person (Organizer Address)",
attendeeAddress: "In Person (Attendee Address)",
link: "Link Meeting",
phone: "Phone Call",
attendeePhone: "Attendee Phone Number",
attendeeDefined: "Custom Attendee Location",
};
return typeToDisplayName[locationType] || locationType;
}
/**
* Get the icon URL for a location type
*/
export function getLocationIconUrl(locationType: string, integration?: string): string | null {
// Handle integration type
if (locationType === "integration" && integration) {
return getAppIconUrl("", integration);
}
// Check default location icons
const typeToIcon: Record<string, string> = {
address: "https://app.cal.com/map-pin-dark.svg",
attendeeAddress: "https://app.cal.com/map-pin-dark.svg",
link: "https://app.cal.com/link.svg",
phone: "https://app.cal.com/phone.svg",
attendeePhone: "https://app.cal.com/phone.svg",
attendeeDefined: "https://app.cal.com/message-pin.svg",
};
return typeToIcon[locationType] || null;
}
/**
* Create a new LocationItem from a location option selection
*/
export function createLocationItemFromOption(
optionValue: string,
optionLabel: string
): LocationItem {
const id = generateLocationId();
// Handle integration options (format: "integrations:app-id")
if (optionValue.startsWith("integrations:")) {
const integration = optionValue.replace("integrations:", "");
return {
id,
type: "integration",
integration,
displayName: optionLabel,
iconUrl: getAppIconUrl("", integration),
};
}
// Handle default location types
const defaultLocation = defaultLocations.find((loc) => loc.type === optionValue);
if (defaultLocation) {
const item: LocationItem = {
id,
type: mapDefaultLocationTypeToApiType(defaultLocation.type),
displayName: defaultLocation.label,
iconUrl: defaultLocation.iconUrl,
};
// Add input fields for specific types
if (defaultLocation.type === DefaultLocationType.InPerson) {
item.address = "";
item.public = true;
} else if (defaultLocation.type === DefaultLocationType.Link) {
item.link = "";
item.public = true;
} else if (defaultLocation.type === DefaultLocationType.UserPhone) {
item.phone = "";
item.public = true;
}
return item;
}
// Fallback
return {
id,
type: optionValue as LocationItem["type"],
displayName: optionLabel,
iconUrl: null,
};
}
/**
* Map DefaultLocationType to API location type
*/
function mapDefaultLocationTypeToApiType(defaultType: string): LocationItem["type"] {
const typeMap: Record<string, LocationItem["type"]> = {
[DefaultLocationType.AttendeeInPerson]: "attendeeAddress",
[DefaultLocationType.InPerson]: "address",
[DefaultLocationType.Phone]: "attendeePhone",
[DefaultLocationType.UserPhone]: "phone",
[DefaultLocationType.Link]: "link",
[DefaultLocationType.SomewhereElse]: "attendeeDefined",
};
return typeMap[defaultType] || (defaultType as LocationItem["type"]);
}
/**
* Check if a location type requires additional input
*/
export function locationRequiresInput(locationType: LocationItem["type"]): boolean {
return ["address", "link", "phone"].includes(locationType);
}
/**
* Get the input field type for a location
*/
export function getLocationInputType(
locationType: LocationItem["type"]
): "text" | "phone" | "url" | null {
switch (locationType) {
case "address":
return "text";
case "link":
return "url";
case "phone":
return "phone";
default:
return null;
}
}
/**
* Get the input placeholder for a location type
*/
export function getLocationInputPlaceholder(locationType: LocationItem["type"]): string {
switch (locationType) {
case "address":
return "Enter address or place";
case "link":
return "https://meet.example.com/join/123456";
case "phone":
return "Enter phone number";
default:
return "";
}
}
/**
* Get the input label for a location type
*/
export function getLocationInputLabel(locationType: LocationItem["type"]): string {
switch (locationType) {
case "address":
return "Address";
case "link":
return "Meeting Link";
case "phone":
return "Phone Number";
default:
return "";
}
}
/**
* Validate a location item before saving
*/
export function validateLocationItem(item: LocationItem): { valid: boolean; error?: string } {
// Integration types don't need additional validation
if (item.type === "integration") {
if (!item.integration) {
return { valid: false, error: "Integration type is required" };
}
return { valid: true };
}
// Address type requires address field
if (item.type === "address") {
if (!item.address || item.address.trim() === "") {
return { valid: false, error: "Address is required" };
}
return { valid: true };
}
// Link type requires link field
if (item.type === "link") {
if (!item.link || item.link.trim() === "") {
return { valid: false, error: "Meeting link is required" };
}
// URL validation with protocol restriction
try {
const url = new URL(item.link);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return { valid: false, error: "Meeting link must use http or https" };
}
} catch {
return { valid: false, error: "Invalid meeting link URL" };
}
return { valid: true };
}
// Phone type requires phone field
if (item.type === "phone") {
if (!item.phone || item.phone.trim() === "") {
return { valid: false, error: "Phone number is required" };
}
return { valid: true };
}
// Other types (attendeeAddress, attendeePhone, attendeeDefined) don't need input
return { valid: true };
}
/**
* Build location options for dropdown from conferencing options and default locations
*/
export function buildLocationOptions(
conferencingOptions: Array<{ type: string; appId: string }>
): LocationOptionGroup[] {
// Cal Video is always available as the default conferencing app
const calVideoOption: LocationOption = {
label: "Cal Video",
iconUrl: getAppIconUrl("daily_video", "cal-video"),
value: "integrations:cal-video",
category: "conferencing",
};
// Group conferencing apps under "conferencing" category
const conferencingAppOptions: LocationOption[] = conferencingOptions
.filter((option) => option.appId !== "cal-video" && option.type !== "daily_video")
.map((option) => ({
label: formatAppIdToDisplayName(option.appId),
iconUrl: getAppIconUrl(option.type, option.appId),
value: `integrations:${option.appId}`,
category: "conferencing",
}));
// Group default locations by their category
const grouped: Record<string, LocationOption[]> = {
conferencing: [calVideoOption, ...conferencingAppOptions],
};
// Add default locations by category
defaultLocations.forEach((location) => {
const option: LocationOption = {
label: location.label,
iconUrl: location.iconUrl,
value: location.type,
category: location.category,
};
if (!grouped[location.category]) {
grouped[location.category] = [];
}
grouped[location.category].push(option);
});
// Convert to array of groups with proper ordering
const categoryOrder = ["conferencing", "in person", "phone", "other"];
const categoryLabels: Record<string, string> = {
conferencing: "Conferencing",
"in person": "In Person",
phone: "Phone",
other: "Other",
};
return categoryOrder
.filter((category) => grouped[category] && grouped[category].length > 0)
.map((category) => ({
category: categoryLabels[category] || category,
options: grouped[category],
}));
}
/**
* Convert a display name back to a location value for API
* Used when selecting a location from a dropdown
*
* @param displayName - The display name shown in UI (e.g., "Google Meet", "In Person (Organizer Address)")
* @param defaultLocationsList - List of default locations to check against
* @returns Location value object for API, or null if not found
*/
export const displayNameToLocationValue = (
displayName: string,
defaultLocationsList: Array<{ label: string; type: string }>
): {
type: string;
integration?: string;
address?: string;
link?: string;
phone?: string;
public?: boolean;
} | null => {
// First check if it's a default location
const defaultLocation = defaultLocationsList.find((loc) => loc.label === displayName);
if (defaultLocation) {
// Map internal location types to API location types
switch (defaultLocation.type) {
case "attendeeInPerson":
return { type: "attendeeAddress" };
case "inPerson":
return { type: "address", address: "", public: true };
case "link":
return { type: "link", link: "", public: true };
case "phone":
return { type: "attendeePhone" };
case "userPhone":
return { type: "phone", phone: "", public: true };
default:
return null;
}
}
// Check if it's a conferencing app (formatted display name)
// e.g., "Google Meet", "Zoom", etc.
const appId = displayName.toLowerCase().replace(/\s+/g, "-");
return { type: "integration", integration: appId };
};