diff --git a/companion/app/(tabs)/(bookings)/booking-detail.tsx b/companion/app/(tabs)/(bookings)/booking-detail.tsx
index 27908182ad..570a9791d7 100644
--- a/companion/app/(tabs)/(bookings)/booking-detail.tsx
+++ b/companion/app/(tabs)/(bookings)/booking-detail.tsx
@@ -1,5 +1,6 @@
import { Stack, useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
+import { Platform } from "react-native";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import { useAuth } from "@/contexts/AuthContext";
import { useBookingByUid } from "@/hooks/useBookings";
@@ -225,57 +226,59 @@ export default function BookingDetail() {
}}
/>
-
- Booking Details
+ {Platform.OS === "ios" && (
+
+ Booking Details
- {/* Header right and left API only works on iOS ATM see: https://docs.expo.dev/versions/unversioned/sdk/router/#stackheaderright */}
-
-
- Actions
-
+ {/* Header right and left API only works on iOS ATM see: https://docs.expo.dev/versions/unversioned/sdk/router/#stackheaderright */}
+
+
+ Actions
+
- {/* Edit Event Section */}
-
- {bookingActionsSections.editEvent.map((action) => (
-
- {action.label}
-
- ))}
+ {/* Edit Event Section */}
+
+ {bookingActionsSections.editEvent.map((action) => (
+
+ {action.label}
+
+ ))}
+
+
+ {/* After Event Section */}
+
+ {bookingActionsSections.afterEvent.map((action) => (
+
+ {action.label}
+
+ ))}
+
+
+ {/* Danger Zone Submenu */}
+
+ {bookingActionsSections.standalone.map((action) => (
+
+ {action.label}
+
+ ))}
+
-
- {/* After Event Section */}
-
- {bookingActionsSections.afterEvent.map((action) => (
-
- {action.label}
-
- ))}
-
-
- {/* Danger Zone Submenu */}
-
- {bookingActionsSections.standalone.map((action) => (
-
- {action.label}
-
- ))}
-
-
-
-
+
+
+ )}
{
- switch (type) {
- case "address":
- return "mappin.and.ellipse";
- case "link":
- return "link";
- case "phone":
- return "phone";
- default:
- return "video";
- }
-};
-
export const AddLocationTrigger = ({
isHeader = false,
locationOptions,
@@ -116,30 +101,17 @@ export const AddLocationTrigger = ({
return trigger;
}
- return (
-
-
-
- {locationOptions.flatMap((group) =>
- group.options
- .filter((opt) => !isLocationAlreadyAdded(locations, opt.value))
- .map((option) => (
-
- {trigger}
-
-
+ const iosPicker = (
+
);
+
+ return iosPicker ?? trigger;
};
export const LocationsList: React.FC = ({
diff --git a/companion/components/LocationsListIOSPicker.ios.tsx b/companion/components/LocationsListIOSPicker.ios.tsx
new file mode 100644
index 0000000000..42e4ead399
--- /dev/null
+++ b/companion/components/LocationsListIOSPicker.ios.tsx
@@ -0,0 +1,71 @@
+import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
+import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import type React from "react";
+import type { LocationItem, LocationOptionGroup } from "@/types/locations";
+
+const getSFSymbolForType = (type: string) => {
+ switch (type) {
+ case "address":
+ return "mappin.and.ellipse";
+ case "link":
+ return "link";
+ case "phone":
+ return "phone";
+ default:
+ return "video";
+ }
+};
+
+function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string): boolean {
+ return locations.some((loc) => {
+ if (loc.type === "integration" && optionValue.startsWith("integrations:")) {
+ return loc.integration === optionValue.replace("integrations:", "");
+ }
+ if (["address", "link", "phone"].includes(loc.type)) {
+ return false;
+ }
+ return loc.type === optionValue;
+ });
+}
+
+interface LocationsListIOSPickerProps {
+ isHeader?: boolean;
+ locationOptions: LocationOptionGroup[];
+ locations: LocationItem[];
+ onSelectOption: (value: string, label: string) => void;
+ trigger: React.ReactNode;
+}
+
+export function LocationsListIOSPicker({
+ isHeader = false,
+ locationOptions,
+ locations,
+ onSelectOption,
+ trigger,
+}: LocationsListIOSPickerProps) {
+ return (
+
+
+
+ {locationOptions.flatMap((group) =>
+ group.options
+ .filter((opt) => !isLocationAlreadyAdded(locations, opt.value))
+ .map((option) => (
+
+ {trigger}
+
+
+ );
+}
diff --git a/companion/components/LocationsListIOSPicker.tsx b/companion/components/LocationsListIOSPicker.tsx
new file mode 100644
index 0000000000..50979c82a6
--- /dev/null
+++ b/companion/components/LocationsListIOSPicker.tsx
@@ -0,0 +1,14 @@
+import type React from "react";
+import type { LocationItem, LocationOptionGroup } from "@/types/locations";
+
+interface LocationsListIOSPickerProps {
+ isHeader?: boolean;
+ locationOptions: LocationOptionGroup[];
+ locations: LocationItem[];
+ onSelectOption: (value: string, label: string) => void;
+ trigger: React.ReactNode;
+}
+
+export function LocationsListIOSPicker(_props: LocationsListIOSPickerProps) {
+ return null;
+}
diff --git a/companion/components/event-type-detail/tabs/AdvancedTab.tsx b/companion/components/event-type-detail/tabs/AdvancedTab.tsx
index d02c8be5ee..cd07f39424 100644
--- a/companion/components/event-type-detail/tabs/AdvancedTab.tsx
+++ b/companion/components/event-type-detail/tabs/AdvancedTab.tsx
@@ -5,10 +5,7 @@
* Uses native iOS pickers via ContextMenu and grouped rows for consistency.
*/
-import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
-import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
-import { isLiquidGlassAvailable } from "expo-glass-effect";
import { useState } from "react";
import {
Alert,
@@ -24,6 +21,7 @@ import {
import { showInfoAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
+import { IOSPickerTrigger } from "./IOSPickerTrigger";
// Interface language options matching API V2 enum
const interfaceLanguageOptions = [
@@ -161,7 +159,7 @@ function SettingRow({
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#E9E9EA", true: "#000000" }}
- thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
+ thumbColor={Platform.OS !== "ios" ? "#FFFFFF" : undefined}
/>
@@ -229,42 +227,6 @@ function NavigationRow({
);
}
-// iOS Native Picker trigger component
-function IOSPickerTrigger({
- options,
- selectedValue,
- onSelect,
-}: {
- options: { label: string; value: string }[];
- selectedValue: string;
- onSelect: (value: string) => void;
-}) {
- return (
-
-
-
- {options.map((opt) => (
-
-
-
-
-
-
-
-
- );
-}
-
interface AdvancedTabProps {
requiresConfirmation: boolean;
setRequiresConfirmation: (value: boolean) => void;
diff --git a/companion/components/event-type-detail/tabs/AvailabilityTab.tsx b/companion/components/event-type-detail/tabs/AvailabilityTab.tsx
index 8400715969..76c4381ff2 100644
--- a/companion/components/event-type-detail/tabs/AvailabilityTab.tsx
+++ b/companion/components/event-type-detail/tabs/AvailabilityTab.tsx
@@ -5,12 +5,10 @@
* Matches the beautiful styling from AvailabilityDetailScreen.
*/
-import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
-import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
-import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import type { Schedule } from "@/services/calcom";
+import { AvailabilityTabIOSPicker } from "./AvailabilityTabIOSPicker";
interface DaySchedule {
day: string;
@@ -103,7 +101,11 @@ function NavigationRow({
{value}
) : null}
-
+
>
) : (
void;
-}) {
- return (
-
-
-
- {options.map((opt) => (
-
-
-
-
-
-
-
-
- );
-}
-
export function AvailabilityTab(props: AvailabilityTabProps) {
const daySchedules = props.getDaySchedules();
const enabledDaysCount = daySchedules.filter((d) => d.available).length;
diff --git a/companion/components/event-type-detail/tabs/AvailabilityTabIOSPicker.ios.tsx b/companion/components/event-type-detail/tabs/AvailabilityTabIOSPicker.ios.tsx
new file mode 100644
index 0000000000..6da009ce43
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/AvailabilityTabIOSPicker.ios.tsx
@@ -0,0 +1,40 @@
+import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
+import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+
+interface AvailabilityTabIOSPickerProps {
+ options: { label: string; value: string }[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function AvailabilityTabIOSPicker({
+ options,
+ selectedValue,
+ onSelect,
+}: AvailabilityTabIOSPickerProps) {
+ return (
+
+
+
+ {options.map((opt) => (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/companion/components/event-type-detail/tabs/AvailabilityTabIOSPicker.tsx b/companion/components/event-type-detail/tabs/AvailabilityTabIOSPicker.tsx
new file mode 100644
index 0000000000..b2d6083b6e
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/AvailabilityTabIOSPicker.tsx
@@ -0,0 +1,9 @@
+interface AvailabilityTabIOSPickerProps {
+ options: { label: string; value: string }[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function AvailabilityTabIOSPicker(_props: AvailabilityTabIOSPickerProps) {
+ return null;
+}
diff --git a/companion/components/event-type-detail/tabs/BasicsTab.tsx b/companion/components/event-type-detail/tabs/BasicsTab.tsx
index e8f0db8768..73f12b17e3 100644
--- a/companion/components/event-type-detail/tabs/BasicsTab.tsx
+++ b/companion/components/event-type-detail/tabs/BasicsTab.tsx
@@ -4,18 +4,15 @@
* iOS Settings style with grouped input rows and section headers.
*/
-import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
-import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
-import { isLiquidGlassAvailable } from "expo-glass-effect";
-import { Platform, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
-
-import { LocationsList, AddLocationTrigger } from "@/components/LocationsList";
-import { createLocationItemFromOption } from "@/utils/locationHelpers";
-import type { LocationItem, LocationOptionGroup } from "@/types/locations";
-import { slugify } from "@/utils/slugify";
import type React from "react";
import { useState } from "react";
+import { Platform, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
+import { AddLocationTrigger, LocationsList } from "@/components/LocationsList";
+import type { LocationItem, LocationOptionGroup } from "@/types/locations";
+import { createLocationItemFromOption } from "@/utils/locationHelpers";
+import { slugify } from "@/utils/slugify";
+import { BasicsTabIOSPicker } from "./BasicsTabIOSPicker";
interface BasicsTabProps {
eventTitle: string;
@@ -159,7 +156,11 @@ function NavigationRow({
{value}
) : null}
-
+
>
) : (
void;
-}) {
- return (
-
-
-
- {options.map((opt) => (
-
-
-
-
-
-
-
-
- );
-}
-
// Toggle row
function SettingRow({
title,
@@ -246,7 +211,7 @@ function SettingRow({
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#E9E9EA", true: "#000000" }}
- thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
+ thumbColor={Platform.OS !== "ios" ? "#FFFFFF" : undefined}
/>
diff --git a/companion/components/event-type-detail/tabs/BasicsTabIOSPicker.ios.tsx b/companion/components/event-type-detail/tabs/BasicsTabIOSPicker.ios.tsx
new file mode 100644
index 0000000000..561f135bce
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/BasicsTabIOSPicker.ios.tsx
@@ -0,0 +1,36 @@
+import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
+import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+
+interface BasicsTabIOSPickerProps {
+ options: string[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function BasicsTabIOSPicker({ options, selectedValue, onSelect }: BasicsTabIOSPickerProps) {
+ return (
+
+
+
+ {options.map((opt) => (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/companion/components/event-type-detail/tabs/BasicsTabIOSPicker.tsx b/companion/components/event-type-detail/tabs/BasicsTabIOSPicker.tsx
new file mode 100644
index 0000000000..1e536df469
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/BasicsTabIOSPicker.tsx
@@ -0,0 +1,9 @@
+interface BasicsTabIOSPickerProps {
+ options: string[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function BasicsTabIOSPicker(_props: BasicsTabIOSPickerProps) {
+ return null;
+}
diff --git a/companion/components/event-type-detail/tabs/IOSPickerTrigger.ios.tsx b/companion/components/event-type-detail/tabs/IOSPickerTrigger.ios.tsx
new file mode 100644
index 0000000000..29f083e589
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/IOSPickerTrigger.ios.tsx
@@ -0,0 +1,36 @@
+import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
+import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+
+interface IOSPickerTriggerProps {
+ options: { label: string; value: string }[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function IOSPickerTrigger({ options, selectedValue, onSelect }: IOSPickerTriggerProps) {
+ return (
+
+
+
+ {options.map((opt) => (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/companion/components/event-type-detail/tabs/IOSPickerTrigger.tsx b/companion/components/event-type-detail/tabs/IOSPickerTrigger.tsx
new file mode 100644
index 0000000000..3f874d9a40
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/IOSPickerTrigger.tsx
@@ -0,0 +1,9 @@
+interface IOSPickerTriggerProps {
+ options: { label: string; value: string }[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function IOSPickerTrigger(_props: IOSPickerTriggerProps) {
+ return null;
+}
diff --git a/companion/components/event-type-detail/tabs/LimitsTab.tsx b/companion/components/event-type-detail/tabs/LimitsTab.tsx
index cb7ee14243..d1079e5c45 100644
--- a/companion/components/event-type-detail/tabs/LimitsTab.tsx
+++ b/companion/components/event-type-detail/tabs/LimitsTab.tsx
@@ -5,10 +5,7 @@
* Uses native iOS ContextMenu for pickers on iOS, Modal-based dropdowns on Android/Web.
*/
-import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
-import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
-import { isLiquidGlassAvailable } from "expo-glass-effect";
import {
Alert,
type Animated,
@@ -20,6 +17,8 @@ import {
View,
} from "react-native";
+import { LimitsTabIOSPicker } from "./LimitsTabIOSPicker";
+
interface FrequencyLimit {
id: number;
value: string;
@@ -146,7 +145,7 @@ function SettingRow({
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#E9E9EA", true: "#000000" }}
- thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
+ thumbColor={Platform.OS !== "ios" ? "#FFFFFF" : undefined}
/>
@@ -154,42 +153,6 @@ function SettingRow({
);
}
-// iOS Native Picker using ContextMenu - only renders the chevron trigger
-function IOSPickerTrigger({
- options,
- selectedValue,
- onSelect,
-}: {
- options: string[];
- selectedValue: string;
- onSelect: (value: string) => void;
-}) {
- return (
-
-
-
- {options.map((opt) => (
-
-
-
-
-
-
-
-
- );
-}
-
// Android/Web picker button (opens modal)
function ModalPickerButton({ value, onPress }: { value: string; onPress: () => void }) {
return (
@@ -234,7 +197,7 @@ function PickerNavigationRow({
{Platform.OS === "ios" ? (
<>
{value}
-
+
>
) : (
{props.minimumNoticeUnit}
-
{limit.unit}
-
@@ -501,7 +464,7 @@ export function LimitsTab(props: LimitsTabProps) {
{Platform.OS === "ios" ? (
{limit.unit}
-
diff --git a/companion/components/event-type-detail/tabs/LimitsTabIOSPicker.ios.tsx b/companion/components/event-type-detail/tabs/LimitsTabIOSPicker.ios.tsx
new file mode 100644
index 0000000000..ad0d4f223b
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/LimitsTabIOSPicker.ios.tsx
@@ -0,0 +1,36 @@
+import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
+import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+
+interface LimitsTabIOSPickerProps {
+ options: string[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function LimitsTabIOSPicker({ options, selectedValue, onSelect }: LimitsTabIOSPickerProps) {
+ return (
+
+
+
+ {options.map((opt) => (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/companion/components/event-type-detail/tabs/LimitsTabIOSPicker.tsx b/companion/components/event-type-detail/tabs/LimitsTabIOSPicker.tsx
new file mode 100644
index 0000000000..19dd8c3f37
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/LimitsTabIOSPicker.tsx
@@ -0,0 +1,9 @@
+interface LimitsTabIOSPickerProps {
+ options: string[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function LimitsTabIOSPicker(_props: LimitsTabIOSPickerProps) {
+ return null;
+}
diff --git a/companion/components/event-type-detail/tabs/RecurringTab.tsx b/companion/components/event-type-detail/tabs/RecurringTab.tsx
index 3447c9738c..24b2a42e88 100644
--- a/companion/components/event-type-detail/tabs/RecurringTab.tsx
+++ b/companion/components/event-type-detail/tabs/RecurringTab.tsx
@@ -4,13 +4,10 @@
* iOS Settings style with grouped rows and section headers.
*/
-import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
-import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
-import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Alert, Platform, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
-
import { openInAppBrowser } from "@/utils/browser";
+import { RecurringTabIOSPicker } from "./RecurringTabIOSPicker";
interface RecurringTabProps {
recurringEnabled: boolean;
@@ -112,7 +109,7 @@ function SettingRow({
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#E9E9EA", true: "#000000" }}
- thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
+ thumbColor={Platform.OS !== "ios" ? "#FFFFFF" : undefined}
/>
@@ -120,42 +117,6 @@ function SettingRow({
);
}
-// iOS Native Picker trigger component
-function IOSPickerTrigger({
- options,
- selectedValue,
- onSelect,
-}: {
- options: { label: string; value: string }[];
- selectedValue: string;
- onSelect: (value: string) => void;
-}) {
- return (
-
-
-
- {options.map((opt) => (
-
-
-
-
-
-
-
-
- );
-}
-
export function RecurringTab({
recurringEnabled,
setRecurringEnabled,
@@ -215,7 +176,7 @@ export function RecurringTab({
{frequencyToLabel[recurringFrequency] || recurringFrequency}
- void;
+}
+
+export function RecurringTabIOSPicker({
+ options,
+ selectedValue,
+ onSelect,
+}: RecurringTabIOSPickerProps) {
+ return (
+
+
+
+ {options.map((opt) => (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/companion/components/event-type-detail/tabs/RecurringTabIOSPicker.tsx b/companion/components/event-type-detail/tabs/RecurringTabIOSPicker.tsx
new file mode 100644
index 0000000000..0ef78d71fa
--- /dev/null
+++ b/companion/components/event-type-detail/tabs/RecurringTabIOSPicker.tsx
@@ -0,0 +1,9 @@
+interface RecurringTabIOSPickerProps {
+ options: { label: string; value: string }[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+}
+
+export function RecurringTabIOSPicker(_props: RecurringTabIOSPickerProps) {
+ return null;
+}
diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx
index a2c5f3f7a6..ca61bfaead 100644
--- a/companion/components/screens/BookingDetailScreen.tsx
+++ b/companion/components/screens/BookingDetailScreen.tsx
@@ -13,8 +13,11 @@ import {
TouchableOpacity,
View,
} from "react-native";
-import { FullScreenModal } from "@/components/FullScreenModal";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { AppPressable } from "@/components/AppPressable";
+import { BookingActionsModal } from "@/components/BookingActionsModal";
+import { FullScreenModal } from "@/components/FullScreenModal";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import {
AlertDialog,
AlertDialogAction,
@@ -33,19 +36,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text as UIText } from "@/components/ui/text";
-import { AppPressable } from "@/components/AppPressable";
-import { BookingActionsModal } from "@/components/BookingActionsModal";
-import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
-import { SvgImage } from "@/components/SvgImage";
import { useAuth } from "@/contexts/AuthContext";
import { useCancelBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { openInAppBrowser } from "@/utils/browser";
-import { defaultLocations, getDefaultLocationIconUrl } from "@/utils/defaultLocations";
-import { formatAppIdToDisplayName } from "@/utils/formatters";
-import { getAppIconUrl } from "@/utils/getAppIconUrl";
// Empty actions result for when no booking is loaded
const EMPTY_ACTIONS: BookingActionsResult = {
@@ -59,136 +55,76 @@ const EMPTY_ACTIONS: BookingActionsResult = {
markNoShow: { visible: false, enabled: false },
};
-// Format date: "Tuesday, November 25, 2025"
-const formatDateFull = (dateString: string): string => {
+// Format date for iOS Calendar style: "Monday, Jan 12, 2026"
+const formatDateCalendarStyle = (dateString: string): string => {
if (!dateString) return "";
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("en-US", {
weekday: "long",
- month: "long",
day: "numeric",
+ month: "short",
year: "numeric",
});
};
-// Format time: "9:40pm - 10:00pm"
-const formatTime12Hour = (dateString: string): string => {
- if (!dateString) return "";
- const date = new Date(dateString);
- if (Number.isNaN(date.getTime())) return "";
- const hours = date.getHours();
- const minutes = date.getMinutes();
- const period = hours >= 12 ? "pm" : "am";
- const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
- const minStr = minutes.toString().padStart(2, "0");
- return `${hour12}:${minStr}${period}`;
-};
+// Format time for iOS Calendar style: "3 PM – 3:30 PM"
+const formatTimeCalendarStyle = (startDateString: string, endDateString: string): string => {
+ if (!startDateString || !endDateString) return "";
+ const startDate = new Date(startDateString);
+ const endDate = new Date(endDateString);
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return "";
-// Get user's local timezone for display
-const getTimezone = (): string => {
- const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
- return timeZone || "";
-};
-
-// Get initials from a name(e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS")
-const getInitials = (name: string): string => {
- if (!name) return "";
- const parts = name.trim().split(/\s+/);
- if (parts.length === 0) return "";
- if (parts.length === 1) {
- return parts[0].charAt(0).toUpperCase();
- }
- // Get first letter of first name and first letter of last name
- return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
-};
-
-// Get location provider info
-const getLocationProvider = (location: string | undefined, metadata?: Record) => {
- // Check metadata for videoCallUrl first
- const videoCallUrl = metadata?.videoCallUrl;
- const locationToCheck = videoCallUrl || location;
-
- if (!locationToCheck) return null;
-
- // Check if it's a video call URL
- if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) {
- // Try to detect provider from URL
- if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) {
- const iconUrl = getAppIconUrl("daily_video", "cal-video");
- return {
- label: "Cal Video",
- iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg",
- url: locationToCheck,
- };
+ const formatTime = (date: Date): string => {
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+ const period = hours >= 12 ? "PM" : "AM";
+ const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
+ if (minutes === 0) {
+ return `${hour12} ${period}`;
}
- // Check for other video providers by URL pattern
- const videoProviders = [
- { pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" },
- {
- pattern: /meet\.google\.com/,
- label: "Google Meet",
- type: "google_video",
- appId: "google-meet",
- },
- {
- pattern: /teams\.microsoft\.com/,
- label: "Microsoft Teams",
- type: "office365_video",
- appId: "msteams",
- },
- ];
-
- for (const provider of videoProviders) {
- if (provider.pattern.test(locationToCheck)) {
- const iconUrl = getAppIconUrl(provider.type, provider.appId);
- return {
- label: provider.label,
- iconUrl: iconUrl,
- url: locationToCheck,
- };
- }
- }
-
- // Generic link meeting
- const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg";
- return {
- label: "Link Meeting",
- iconUrl: linkIconUrl,
- url: locationToCheck,
- };
- }
-
- // Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video")
- if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) {
- const appId = locationToCheck.replace("integrations:", "");
- const iconUrl = getAppIconUrl("", appId);
-
- if (iconUrl) {
- return {
- label: formatAppIdToDisplayName(appId),
- iconUrl: iconUrl,
- url: null,
- };
- }
- }
-
- // Check if it's a default location type
- const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck);
- if (defaultLocation) {
- return {
- label: defaultLocation.label,
- iconUrl: defaultLocation.iconUrl,
- url: null,
- };
- }
-
- // Fallback: return as plain text location
- return {
- label: locationToCheck as string,
- iconUrl: null,
- url: null,
+ return `${hour12}:${minutes.toString().padStart(2, "0")} ${period}`;
};
+
+ return `${formatTime(startDate)} – ${formatTime(endDate)}`;
+};
+
+// Format duration for display: "30 min" or "1 hr" or "1 hr 30 min"
+const formatDuration = (minutes: number): string => {
+ if (minutes < 60) {
+ return `${minutes} min`;
+ }
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+ if (remainingMinutes === 0) {
+ return hours === 1 ? "1 hr" : `${hours} hrs`;
+ }
+ return `${hours} hr ${remainingMinutes} min`;
+};
+
+// Calculate duration from start and end times
+const calculateDuration = (startDateString: string, endDateString: string): number => {
+ if (!startDateString || !endDateString) return 0;
+ const startDate = new Date(startDateString);
+ const endDate = new Date(endDateString);
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return 0;
+ return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60));
+};
+
+const getMeetingUrl = (booking: Booking | null): string | null => {
+ if (!booking) return null;
+
+ const videoCallUrl = booking.responses?.videoCallUrl;
+ if (typeof videoCallUrl === "string" && videoCallUrl.startsWith("http")) {
+ return videoCallUrl;
+ }
+
+ const location = booking.location;
+ if (typeof location === "string" && location.startsWith("http")) {
+ return location;
+ }
+
+ return null;
};
export interface BookingDetailScreenProps {
@@ -242,6 +178,7 @@ export function BookingDetailScreen({
const [showActionsModal, setShowActionsModal] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [cancellationReason, setCancellationReason] = useState("");
+ const [participantsExpanded, setParticipantsExpanded] = useState(true);
// Cancel booking mutation
const cancelBookingMutation = useCancelBooking();
@@ -402,6 +339,14 @@ export function BookingDetailScreen({
});
}, [booking, router]);
+ const meetingUrl = useMemo(() => getMeetingUrl(booking ?? null), [booking]);
+
+ const handleJoinMeeting = useCallback(() => {
+ if (meetingUrl) {
+ openInAppBrowser(meetingUrl, "meeting link");
+ }
+ }, [meetingUrl]);
+
// Expose action handlers to parent component (for iOS header menu)
useEffect(() => {
if (booking && onActionsReady) {
@@ -427,15 +372,6 @@ export function BookingDetailScreen({
openMarkNoShowModal,
]);
- const handleJoinMeeting = () => {
- if (!booking?.location) return;
-
- const provider = getLocationProvider(booking.location);
- if (provider?.url) {
- openInAppBrowser(provider.url, "meeting link");
- }
- };
-
const dropdownActions = useMemo(() => {
if (!booking) return [];
@@ -520,7 +456,7 @@ export function BookingDetailScreen({
if (isLoading) {
return (
-
+
Loading booking...
@@ -530,8 +466,8 @@ export function BookingDetailScreen({
if (error || !booking) {
const errorMessage = error?.message || "Booking not found";
return (
-
-
+
+
{errorMessage}
@@ -544,10 +480,55 @@ export function BookingDetailScreen({
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
- const dateFormatted = formatDateFull(startTime);
- const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
- const timezone = getTimezone();
- const locationProvider = getLocationProvider(booking.location, booking.responses);
+ const dateFormatted = formatDateCalendarStyle(startTime);
+ const timeFormatted = formatTimeCalendarStyle(startTime, endTime);
+
+ const isRecurring =
+ booking.recurringEventId || (booking as { recurringBookingUid?: string }).recurringBookingUid;
+
+ const duration =
+ (booking as { duration?: number }).duration || calculateDuration(startTime, endTime);
+ const durationFormatted = duration > 0 ? formatDuration(duration) : null;
+
+ const eventTypeSlug = booking.eventType?.slug;
+
+ const hostsCount = booking.hosts?.length || (booking.user ? 1 : 0);
+ const attendeesCount = booking.attendees?.length || 0;
+ const guestsCount = (booking as { guests?: string[] }).guests?.length || 0;
+ const totalParticipants = hostsCount + attendeesCount + guestsCount;
+
+ const isPastBooking = new Date(endTime) < new Date();
+ const normalizedStatus = booking.status.toLowerCase();
+
+ const getAttendeeStatusIcon = (attendee: { noShow?: boolean; absent?: boolean }) => {
+ const isNoShow = attendee.noShow || attendee.absent;
+
+ if (isPastBooking && isNoShow) {
+ return {
+ name: "close-circle" as const,
+ color: "#FF3B30",
+ label: "No-show",
+ };
+ }
+
+ if (normalizedStatus === "pending") {
+ return {
+ name: "help-circle" as const,
+ color: "#8E8E93",
+ label: "Pending",
+ };
+ }
+
+ if (normalizedStatus === "cancelled" || normalizedStatus === "rejected") {
+ return {
+ name: "close-circle-outline" as const,
+ color: "#8E8E93",
+ label: null,
+ };
+ }
+
+ return { name: "checkmark-circle" as const, color: "#34C759", label: null };
+ };
return (
<>
@@ -557,236 +538,202 @@ export function BookingDetailScreen({
options={{
headerRight: () => (
-
-
-
-
+
+ {meetingUrl && (
+
+
-
+ )}
+
+
+
+
+
+
-
- {dropdownActions.map((action, index) => (
-
- {index === destructiveStartIndex && destructiveStartIndex > 0 && (
-
- )}
-
-
-
- {action.label}
-
-
-
- ))}
-
-
+
+ {dropdownActions.map((action, index) => (
+
+ {index === destructiveStartIndex && destructiveStartIndex > 0 && (
+
+ )}
+
+
+
+ {action.label}
+
+
+
+ ))}
+
+
+
),
}}
/>
)}
-
+
}
>
- {/* Title */}
-
- {booking.title}
-
- {dateFormatted} {timeFormatted} ({timezone})
+ {/* Title Section - iOS Calendar Style */}
+
+
+ {booking.title}
-
- {/* Who Section */}
-
- Who
- {/* Show host from user field or hosts array */}
- {booking.user || (booking.hosts && booking.hosts.length > 0) ? (
-
- {booking.user ? (
-
-
-
- {getInitials(booking.user.name)}
-
-
-
-
-
- {booking.user.name}
-
-
- host
-
-
- {booking.user.email}
-
+ {eventTypeSlug || durationFormatted ? (
+
+ {eventTypeSlug ? (
+ {eventTypeSlug}
+ ) : null}
+ {eventTypeSlug && durationFormatted ? (
+ •
+ ) : null}
+ {durationFormatted ? (
+
+
+ {durationFormatted}
+
- ) : booking.hosts && booking.hosts.length > 0 ? (
- booking.hosts.map((host, hostIndex) => (
- 0 ? "mt-4" : ""}`}
- >
-
-
- {getInitials(host.name || "Host")}
-
-
-
-
-
- {host.name || "Host"}
-
-
- host
-
-
- {host.email && {host.email}}
-
-
- ))
) : null}
) : null}
- {booking.attendees && booking.attendees.length > 0 ? (
-
- {booking.attendees.map((attendee, index) => {
- const isNoShow =
- (attendee as { noShow?: boolean; absent?: boolean }).noShow === true ||
- (attendee as { noShow?: boolean; absent?: boolean }).absent === true;
+
+ {dateFormatted}
+ {timeFormatted}
+
+ {isRecurring ? (
+ Repeats weekly
+ ) : null}
+
+
+ {/* Participants Card - iOS Calendar Style (Expandable) */}
+
+ setParticipantsExpanded(!participantsExpanded)}>
+
+ Participants
+
+ {totalParticipants}
+
+
+
+
+
+ {participantsExpanded ? (
+
+ {/* Hosts */}
+ {booking.hosts && booking.hosts.length > 0 ? (
+ booking.hosts.map((host, index) => (
+ 0 ? "border-t border-[#E5E5EA]" : ""
+ }`}
+ >
+
+
+ {host.name || host.email || "Host"}
+
+ Organizer
+
+ ))
+ ) : booking.user ? (
+
+
+
+ {booking.user.name || booking.user.email}
+
+ Organizer
+
+ ) : null}
+
+ {/* Attendees */}
+ {booking.attendees?.map((attendee, index) => {
+ const statusIcon = getAttendeeStatusIcon(attendee);
return (
0 ? "mt-4" : ""}`}
+ className={`flex-row items-center py-2 ${
+ index > 0 || booking.hosts?.length || booking.user
+ ? "border-t border-[#E5E5EA]"
+ : ""
+ }`}
>
-
-
- {getInitials(attendee.name)}
+
+
+ {attendee.name || attendee.email}
+
+ {statusIcon.label ? (
+
+ {statusIcon.label}
-
-
-
-
- {attendee.name}
-
- {isNoShow && (
-
-
-
- No-show
-
-
- )}
-
-
- {attendee.email}
-
-
+ ) : null}
);
})}
+
+ {/* Guests */}
+ {(booking as { guests?: string[] }).guests?.map((guestEmail, index) => (
+ 0 ||
+ booking.attendees?.length ||
+ booking.hosts?.length ||
+ booking.user
+ ? "border-t border-[#E5E5EA]"
+ : ""
+ }`}
+ >
+
+
+ {guestEmail}
+
+ Guest
+
+ ))}
) : null}
- {/* Where Section */}
- {locationProvider ? (
-
- Where
- {locationProvider.url ? (
-
- {locationProvider.iconUrl ? (
-
- ) : null}
- {locationProvider.label}:
-
- {locationProvider.url}
-
-
- ) : (
-
- {locationProvider.iconUrl ? (
-
- ) : null}
- {locationProvider.label}
-
- )}
-
- ) : null}
-
- {/* Recurring Event Section */}
- {booking.recurringEventId ||
- (booking as { recurringBookingUid?: string }).recurringBookingUid ? (
-
-
- This is part of a recurring event
-
-
- ) : null}
-
- {/* Description Section */}
+ {/* Notes Card (if available) */}
{booking.description ? (
-
- Description
- {booking.description}
+
+
+
+ Notes
+
+ {booking.description}
+
) : null}
-
- {/* Join Meeting Button */}
- {locationProvider?.url ? (
-
- {locationProvider.iconUrl ? (
-
- ) : null}
-
- Join {locationProvider.label}
-
-
- ) : null}
{/* Booking Actions Modal */}
@@ -808,7 +755,7 @@ export function BookingDetailScreen({
/>
{/* Cancelling overlay */}
- {isCancelling && (
+ {isCancelling ? (
@@ -817,7 +764,7 @@ export function BookingDetailScreen({
- )}
+ ) : null}
{/* Web/Extension: Cancel Event Modal */}