diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx
index 7ee3dae204..80375ddf8c 100644
--- a/companion/app/(tabs)/(availability)/index.tsx
+++ b/companion/app/(tabs)/(availability)/index.tsx
@@ -94,7 +94,7 @@ export default function Availability() {
Availability
diff --git a/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx b/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx
index 5e221d92e3..407f562823 100644
--- a/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx
+++ b/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx
@@ -1,10 +1,11 @@
import * as Clipboard from "expo-clipboard";
import { Stack, useLocalSearchParams } from "expo-router";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useMemo, useRef } from "react";
import { Alert } from "react-native";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import { useAuth } from "@/contexts/AuthContext";
-import { type Booking, CalComAPIService } from "@/services/calcom";
+import { useBookingByUid } from "@/hooks/useBookings";
+import type { Booking } from "@/services/calcom";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { openInAppBrowser } from "@/utils/browser";
@@ -59,10 +60,11 @@ const getMeetingUrl = (booking: Booking | null): string | null => {
};
export default function BookingDetailIOS() {
- "use no memo";
const { uid } = useLocalSearchParams<{ uid: string }>();
const { userInfo } = useAuth();
- const [booking, setBooking] = useState(null);
+
+ // Use React Query hook for booking data - single source of truth
+ const { data: booking, isLoading, error, refetch, isRefetching } = useBookingByUid(uid);
// Ref to store action handlers from BookingDetailScreen
const actionHandlersRef = useRef(null);
@@ -72,17 +74,6 @@ export default function BookingDetailIOS() {
actionHandlersRef.current = handlers;
}, []);
- // Fetch booking data for the iOS header menu actions
- useEffect(() => {
- if (uid) {
- CalComAPIService.getBookingByUid(uid)
- .then(setBooking)
- .catch(() => {
- // Error handling is done in BookingDetailScreen
- });
- }
- }, [uid]);
-
// Get the month name from booking start date for back button
const monthName = useMemo(() => {
const startTime = booking?.start || booking?.startTime;
@@ -90,7 +81,7 @@ export default function BookingDetailIOS() {
}, [booking?.start, booking?.startTime]);
// Get meeting URL for Join button
- const meetingUrl = useMemo(() => getMeetingUrl(booking), [booking]);
+ const meetingUrl = useMemo(() => getMeetingUrl(booking ?? null), [booking]);
// Handle join meeting
const handleJoinMeeting = useCallback(() => {
@@ -119,89 +110,44 @@ export default function BookingDetailIOS() {
});
}, [booking, userInfo?.id, userInfo?.email]);
- // Action handlers that use the booking data
- const handleReschedule = useCallback(() => {
- if (actionHandlersRef.current?.openRescheduleModal) {
- actionHandlersRef.current.openRescheduleModal();
+ // Invoke a handler by name - only accesses ref at invocation time (event handler)
+ // This avoids creating closures that capture the ref during render
+ const invokeHandler = useCallback((handlerName: keyof ActionHandlers, errorMessage: string) => {
+ const handlers = actionHandlersRef.current;
+ if (handlers?.[handlerName]) {
+ (handlers[handlerName] as () => void)();
} else {
- Alert.alert("Error", "Unable to reschedule. Please try again.");
+ Alert.alert("Error", errorMessage);
}
}, []);
- const handleEditLocation = useCallback(() => {
- if (actionHandlersRef.current?.openEditLocationModal) {
- actionHandlersRef.current.openEditLocationModal();
- } else {
- Alert.alert("Error", "Unable to edit location. Please try again.");
- }
- }, []);
-
- const handleAddGuests = useCallback(() => {
- if (actionHandlersRef.current?.openAddGuestsModal) {
- actionHandlersRef.current.openAddGuestsModal();
- } else {
- Alert.alert("Error", "Unable to add guests. Please try again.");
- }
- }, []);
-
- const handleViewRecordings = useCallback(() => {
- if (actionHandlersRef.current?.openViewRecordingsModal) {
- actionHandlersRef.current.openViewRecordingsModal();
- } else {
- Alert.alert("Error", "Unable to view recordings. Please try again.");
- }
- }, []);
-
- const handleSessionDetails = useCallback(() => {
- if (actionHandlersRef.current?.openMeetingSessionDetailsModal) {
- actionHandlersRef.current.openMeetingSessionDetailsModal();
- } else {
- Alert.alert("Error", "Unable to view session details. Please try again.");
- }
- }, []);
-
- const handleMarkNoShow = useCallback(() => {
- if (actionHandlersRef.current?.openMarkNoShowModal) {
- actionHandlersRef.current.openMarkNoShowModal();
- } else {
- Alert.alert("Error", "Unable to mark no-show. Please try again.");
- }
- }, []);
-
- const handleReport = useCallback(() => {
- Alert.alert("Report Booking", "Report booking functionality is not yet available");
- }, []);
-
- const handleCancel = useCallback(() => {
- if (actionHandlersRef.current?.handleCancelBooking) {
- actionHandlersRef.current.handleCancelBooking();
- } else {
- Alert.alert("Error", "Unable to cancel. Please try again.");
- }
- }, []);
-
- // Define booking actions organized by sections
+ // Define and filter booking actions
+ // Actions store handler metadata instead of closures to avoid capturing ref during render
const bookingActionsSections = useMemo(() => {
+ // Define all actions with handler metadata (not closures)
const allEditEventActions = [
{
id: "reschedule",
label: "Reschedule Booking",
icon: "calendar" as const,
- onPress: handleReschedule,
+ handlerName: "openRescheduleModal" as const,
+ errorMessage: "Unable to reschedule. Please try again.",
gatingKey: "reschedule" as const,
},
{
id: "edit-location",
label: "Edit Location",
icon: "location" as const,
- onPress: handleEditLocation,
+ handlerName: "openEditLocationModal" as const,
+ errorMessage: "Unable to edit location. Please try again.",
gatingKey: "changeLocation" as const,
},
{
id: "add-guests",
label: "Add Guests",
icon: "person.badge.plus" as const,
- onPress: handleAddGuests,
+ handlerName: "openAddGuestsModal" as const,
+ errorMessage: "Unable to add guests. Please try again.",
gatingKey: "addGuests" as const,
},
];
@@ -211,21 +157,24 @@ export default function BookingDetailIOS() {
id: "view-recordings",
label: "View Recordings",
icon: "video" as const,
- onPress: handleViewRecordings,
+ handlerName: "openViewRecordingsModal" as const,
+ errorMessage: "Unable to view recordings. Please try again.",
gatingKey: "viewRecordings" as const,
},
{
id: "session-details",
label: "Meeting Session Details",
icon: "info.circle" as const,
- onPress: handleSessionDetails,
+ handlerName: "openMeetingSessionDetailsModal" as const,
+ errorMessage: "Unable to view session details. Please try again.",
gatingKey: "meetingSessionDetails" as const,
},
{
id: "mark-no-show",
label: "Mark as No-Show",
icon: "eye.slash" as const,
- onPress: handleMarkNoShow,
+ handlerName: "openMarkNoShowModal" as const,
+ errorMessage: "Unable to mark no-show. Please try again.",
gatingKey: "markNoShow" as const,
},
];
@@ -235,7 +184,11 @@ export default function BookingDetailIOS() {
id: "report",
label: "Report Booking",
icon: "flag" as const,
- onPress: handleReport,
+ handlerName: null,
+ errorMessage: null,
+ customHandler: () => {
+ Alert.alert("Report Booking", "Report booking functionality is not yet available");
+ },
destructive: true,
gatingKey: null,
},
@@ -243,12 +196,14 @@ export default function BookingDetailIOS() {
id: "cancel",
label: "Cancel Event",
icon: "xmark.circle" as const,
- onPress: handleCancel,
+ handlerName: "handleCancelBooking" as const,
+ errorMessage: "Unable to cancel. Please try again.",
destructive: true,
gatingKey: "cancel" as const,
},
];
+ // Filter actions based on gating logic
const filterAction = (action: { gatingKey: keyof typeof actions | null }) => {
if (action.gatingKey === null) return true;
const gating = actions[action.gatingKey];
@@ -260,17 +215,7 @@ export default function BookingDetailIOS() {
afterEvent: allAfterEventActions.filter(filterAction),
standalone: allStandaloneActions.filter(filterAction),
};
- }, [
- actions,
- handleReschedule,
- handleEditLocation,
- handleAddGuests,
- handleViewRecordings,
- handleSessionDetails,
- handleMarkNoShow,
- handleReport,
- handleCancel,
- ]);
+ }, [actions]);
return (
<>
@@ -316,7 +261,7 @@ export default function BookingDetailIOS() {
invokeHandler(action.handlerName, action.errorMessage)}
>
{action.label}
@@ -329,7 +274,7 @@ export default function BookingDetailIOS() {
invokeHandler(action.handlerName, action.errorMessage)}
>
{action.label}
@@ -342,7 +287,13 @@ export default function BookingDetailIOS() {
{
+ if (action.customHandler) {
+ action.customHandler();
+ } else if (action.handlerName && action.errorMessage) {
+ invokeHandler(action.handlerName, action.errorMessage);
+ }
+ }}
destructive
>
{action.label}
@@ -353,7 +304,14 @@ export default function BookingDetailIOS() {
-
+
>
);
}
diff --git a/companion/app/(tabs)/(bookings)/booking-detail.tsx b/companion/app/(tabs)/(bookings)/booking-detail.tsx
index 243dd89a1e..40d7c410e6 100644
--- a/companion/app/(tabs)/(bookings)/booking-detail.tsx
+++ b/companion/app/(tabs)/(bookings)/booking-detail.tsx
@@ -1,9 +1,9 @@
import { Stack, useLocalSearchParams } from "expo-router";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useMemo, useRef } from "react";
import { Alert } from "react-native";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import { useAuth } from "@/contexts/AuthContext";
-import { type Booking, CalComAPIService } from "@/services/calcom";
+import { useBookingByUid } from "@/hooks/useBookings";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
// Empty actions result for when no booking is loaded
@@ -33,7 +33,9 @@ export default function BookingDetail() {
"use no memo";
const { uid } = useLocalSearchParams<{ uid: string }>();
const { userInfo } = useAuth();
- const [booking, setBooking] = useState(null);
+
+ // Use React Query hook for booking data - single source of truth
+ const { data: booking, isLoading, error, refetch, isRefetching } = useBookingByUid(uid);
// Ref to store action handlers from BookingDetailScreen
const actionHandlersRef = useRef(null);
@@ -43,17 +45,6 @@ export default function BookingDetail() {
actionHandlersRef.current = handlers;
}, []);
- // Fetch booking data for the iOS header menu actions
- useEffect(() => {
- if (uid) {
- CalComAPIService.getBookingByUid(uid)
- .then(setBooking)
- .catch(() => {
- // Error handling is done in BookingDetailScreen
- });
- }
- }, [uid]);
-
// Compute actions using centralized gating (same as BookingDetailScreen)
const actions = useMemo(() => {
if (!booking) return EMPTY_ACTIONS;
@@ -286,7 +277,14 @@ export default function BookingDetail() {
-
+
{/* Action Modals for iOS header menu */}
>
diff --git a/companion/app/(tabs)/(bookings)/index.ios.tsx b/companion/app/(tabs)/(bookings)/index.ios.tsx
index 50ce112364..7f6e4d83d0 100644
--- a/companion/app/(tabs)/(bookings)/index.ios.tsx
+++ b/companion/app/(tabs)/(bookings)/index.ios.tsx
@@ -38,17 +38,19 @@ export default function Bookings() {
type: "action",
label: option.label,
icon: {
- name:
- option.key === "upcoming"
- ? "calendar.badge.clock"
+ name: isSelected
+ ? "checkmark.circle.fill"
+ : option.key === "upcoming"
+ ? "calendar"
: option.key === "unconfirmed"
- ? "calendar.badge.exclamationmark"
- : option.key === "past"
- ? "calendar.badge.checkmark"
- : "calendar.badge.minus",
+ ? "questionmark.circle"
+ : option.key === "recurring"
+ ? "repeat.circle"
+ : option.key === "past"
+ ? "checkmark.circle"
+ : "xmark.circle",
type: "sfSymbol",
},
- state: isSelected ? "on" : "off",
onPress: () => {
handleFilterChange(option.key);
},
diff --git a/companion/app/(tabs)/(bookings)/index.tsx b/companion/app/(tabs)/(bookings)/index.tsx
index b2921afaf0..aec5e06d3d 100644
--- a/companion/app/(tabs)/(bookings)/index.tsx
+++ b/companion/app/(tabs)/(bookings)/index.tsx
@@ -1,56 +1,41 @@
import { Ionicons } from "@expo/vector-icons";
-import SegmentedControl from "@react-native-segmented-control/segmented-control";
-import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { useState } from "react";
-import { Text, TextInput, TouchableOpacity, View } from "react-native";
+import { Text, TextInput, View } from "react-native";
import { BookingListScreen } from "@/components/booking-list-screen/BookingListScreen";
import { Header } from "@/components/Header";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { AppPressable } from "@/components/AppPressable";
import { useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
-import type { EventType } from "@/services/calcom";
-import { CalComAPIService } from "@/services/calcom";
-import { safeLogError } from "@/utils/safeLogger";
+import { useEventTypes } from "@/hooks";
export default function Bookings() {
const [searchQuery, setSearchQuery] = useState("");
- const [showFilterModal, setShowFilterModal] = useState(false);
- const [eventTypes, setEventTypes] = useState([]);
const [selectedEventTypeId, setSelectedEventTypeId] = useState(null);
const [selectedEventTypeLabel, setSelectedEventTypeLabel] = useState(null);
- const [eventTypesLoading, setEventTypesLoading] = useState(false);
+
+ // Use React Query hook for event types (same as iOS for unified caching)
+ const { data: eventTypes = [], isLoading: eventTypesLoading } = useEventTypes();
// Use the active booking filter hook
- const { activeFilter, filterLabels, activeIndex, filterParams, handleSegmentChange } =
- useActiveBookingFilter("upcoming", () => {
+ const { activeFilter, filterOptions, filterParams, handleFilterChange } = useActiveBookingFilter(
+ "upcoming",
+ () => {
// Clear dependent filters when status filter changes
setSearchQuery("");
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
- });
+ }
+ );
const handleSearch = (query: string) => {
setSearchQuery(query);
};
- const fetchEventTypes = async () => {
- setEventTypesLoading(true);
- try {
- const types = await CalComAPIService.getEventTypes();
- setEventTypes(types);
- setEventTypesLoading(false);
- } catch (err) {
- safeLogError("Error fetching event types:", err);
- // Error is logged but not displayed to user for event type filter
- setEventTypesLoading(false);
- }
- };
-
- const handleFilterButtonPress = () => {
- setShowFilterModal(true);
- if (eventTypes.length === 0) {
- fetchEventTypes();
- }
- };
-
const clearEventTypeFilter = () => {
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
@@ -63,84 +48,97 @@ export default function Bookings() {
setSelectedEventTypeId(eventTypeId);
setSelectedEventTypeLabel(label || null);
}
- setShowFilterModal(false);
};
- const supportsLiquidGlass = isLiquidGlassAvailable();
-
- const renderSegmentedControl = () => {
- const segmentedControlContent = (
-
- );
+ const renderFilterControls = () => {
+ const filterLabel =
+ selectedEventTypeId !== null ? selectedEventTypeLabel || "Event Type" : "Filter";
return (
- <>
- {supportsLiquidGlass ? (
-
- {segmentedControlContent}
-
- ) : (
-
- {segmentedControlContent}
-
- )}
-
-
-
+
+ {/* Dropdown menu for event type filter */}
+
+
+
+
+
+ {filterLabel}
+
+
+
+
+
+
-
-
- Filter
-
-
-
-
-
+ {/* Clear filter option */}
+ handleEventTypeSelect(null)}
+ >
+ All Event Types
+
+
+ {/* Event type options */}
+ {eventTypes.map((eventType) => (
+ handleEventTypeSelect(eventType.id, eventType.title)}
+ >
+
+ {eventType.title}
+
+
+ ))}
+
+ {/* Loading state */}
+ {eventTypesLoading && eventTypes.length === 0 && (
+ {}}>
+ Loading...
+
+ )}
+
+
+
+
+
- {selectedEventTypeId !== null ? (
-
-
- Filtered by {selectedEventTypeLabel || "event type"}
-
-
- Clear filter
-
-
- ) : null}
- >
+
);
};
return (
}
- renderFilterControls={renderSegmentedControl}
- showFilterModal={showFilterModal}
- setShowFilterModal={setShowFilterModal}
+ renderHeader={() => (
+ void}
+ />
+ )}
+ renderFilterControls={renderFilterControls}
eventTypes={eventTypes}
eventTypesLoading={eventTypesLoading}
searchQuery={searchQuery}
diff --git a/companion/app/(tabs)/(event-types)/event-type-detail.tsx b/companion/app/(tabs)/(event-types)/event-type-detail.tsx
index c3772adc15..d37455c468 100644
--- a/companion/app/(tabs)/(event-types)/event-type-detail.tsx
+++ b/companion/app/(tabs)/(event-types)/event-type-detail.tsx
@@ -14,11 +14,18 @@ import {
View,
} from "react-native";
import { AppPressable } from "@/components/AppPressable";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { AdvancedTab } from "@/components/event-type-detail/tabs/AdvancedTab";
import { AvailabilityTab } from "@/components/event-type-detail/tabs/AvailabilityTab";
import { BasicsTab } from "@/components/event-type-detail/tabs/BasicsTab";
import { LimitsTab } from "@/components/event-type-detail/tabs/LimitsTab";
import { RecurringTab } from "@/components/event-type-detail/tabs/RecurringTab";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { truncateTitle } from "@/components/event-type-detail/utils";
import { buildPartialUpdatePayload } from "@/components/event-type-detail/utils/buildPartialUpdatePayload";
import {
@@ -1113,19 +1120,72 @@ export default function EventTypeDetail() {
const saveButtonText = id === "new" ? "Create" : "Save";
const renderHeaderLeft = () => (
- router.back()} className="px-2 py-2">
-
-
+
+ router.back()} className="px-2 py-2">
+
+
+
);
const renderHeaderRight = () => (
-
- {saveButtonText}
-
+
+
+ {/* Tab Navigation Dropdown Menu */}
+
+
+
+
+ {tabs.find((tab) => tab.id === activeTab)?.label ?? "Basics"}
+
+
+
+
+
+
+ {tabs.map((tab) => {
+ const isSelected = activeTab === tab.id;
+ return (
+ setActiveTab(tab.id)}>
+
+
+
+ {tab.label}
+
+
+
+ );
+ })}
+
+
+
+ {/* Save Button */}
+
+ {saveButtonText}
+
+
+
);
return (
@@ -1155,10 +1215,9 @@ export default function EventTypeDetail() {
{tabs.map((tab) => (
-
+ {/* Horizontal tabs only shown on web; Android uses header dropdown menu */}
+
) : null}
-
-
- Hidden
-
+ {activeTab === "basics" && (
+
+
+ Hidden
+
+
+
+
+ Preview
+
+
+
+
+ Copy Link
+
+
+
+
+ Delete
+
+
-
-
- Preview
-
-
-
-
- Copy Link
-
-
-
-
- Delete
-
-
-
+ )}
>
diff --git a/companion/app/(tabs)/(event-types)/index.ios.tsx b/companion/app/(tabs)/(event-types)/index.ios.tsx
index 930ebf24f5..ec74ce9649 100644
--- a/companion/app/(tabs)/(event-types)/index.ios.tsx
+++ b/companion/app/(tabs)/(event-types)/index.ios.tsx
@@ -7,7 +7,6 @@ import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import {
- ActionSheetIOS,
Alert,
Pressable,
RefreshControl,
@@ -27,13 +26,13 @@ import {
useEventTypes,
useUserProfile,
} from "@/hooks";
+import { useEventTypeFilter } from "@/hooks/useEventTypeFilter";
import { CalComAPIService, type EventType } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { getEventDuration } from "@/utils/getEventDuration";
import { offlineAwareRefresh } from "@/utils/network";
-import { normalizeMarkdown } from "@/utils/normalizeMarkdown";
import { slugify } from "@/utils/slugify";
export default function EventTypesIOS() {
@@ -73,18 +72,34 @@ export default function EventTypesIOS() {
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
- // Filter event types based on search query
+ // Event type filter and sort hook
+ const {
+ sortBy,
+ filters,
+ setSortBy,
+ toggleFilter,
+ resetFilters,
+ filteredAndSortedEventTypes,
+ activeFilterCount,
+ } = useEventTypeFilter();
+
+ // Filter event types based on search query and filter/sort options
const filteredEventTypes = useMemo(() => {
- if (searchQuery.trim() === "") {
- return eventTypes;
+ // First apply filter/sort from the hook
+ let filtered = filteredAndSortedEventTypes(eventTypes);
+
+ // Then apply search query filter
+ if (searchQuery.trim() !== "") {
+ const searchLower = searchQuery.toLowerCase();
+ filtered = filtered.filter(
+ (eventType) =>
+ eventType.title.toLowerCase().includes(searchLower) ||
+ eventType.description?.toLowerCase().includes(searchLower)
+ );
}
- const searchLower = searchQuery.toLowerCase();
- return eventTypes.filter(
- (eventType) =>
- eventType.title.toLowerCase().includes(searchLower) ||
- eventType.description?.toLowerCase().includes(searchLower)
- );
- }, [eventTypes, searchQuery]);
+
+ return filtered;
+ }, [eventTypes, searchQuery, filteredAndSortedEventTypes]);
const handleSearch = (query: string) => {
setSearchQuery(query);
@@ -94,34 +109,6 @@ export default function EventTypesIOS() {
handleEdit(eventType);
};
- const handleEventTypeLongPress = (eventType: EventType) => {
- ActionSheetIOS.showActionSheetWithOptions(
- {
- options: ["Cancel", "Edit", "Duplicate", "Delete"],
- destructiveButtonIndex: 3, // Delete button
- cancelButtonIndex: 0,
- title: eventType.title,
- message: eventType.description ? normalizeMarkdown(eventType.description) : undefined,
- },
- (buttonIndex) => {
- switch (buttonIndex) {
- case 1: // Edit
- handleEdit(eventType);
- break;
- case 2: // Duplicate
- handleDuplicate(eventType);
- break;
- case 3: // Delete
- handleDelete(eventType);
- break;
- default:
- // Cancel - do nothing
- break;
- }
- }
- );
- };
-
const handleCopyLink = async (eventType: EventType) => {
try {
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
@@ -315,16 +302,21 @@ export default function EventTypesIOS() {
);
};
- // Sort by menu handler (dummy for now)
- const handleSortByOption = (option: string) => {
- console.log("Sort by:", option);
- // TODO: Implement actual sorting logic
+ // Sort by menu handler
+ const handleSortByOption = (option: "alphabetical" | "newest" | "duration") => {
+ setSortBy(option);
};
- // Filter menu handler (dummy for now)
- const handleFilterOption = (option: string) => {
- console.log("Filter by:", option);
- // TODO: Implement actual filtering logic
+ // Filter menu handler - toggle filters
+ const handleFilterToggle = (
+ filterKey:
+ | "hiddenOnly"
+ | "paidOnly"
+ | "seatedOnly"
+ | "requiresConfirmationOnly"
+ | "recurringOnly"
+ ) => {
+ toggleFilter(filterKey);
};
if (loading) {
@@ -391,39 +383,66 @@ export default function EventTypesIOS() {
{/* Sort by Submenu - opens as separate submenu */}
handleSortByOption("alphabetical")}
>
Alphabetical
handleSortByOption("newest")}
>
Newest First
- handleSortByOption("duration")}>
+ handleSortByOption("duration")}
+ >
By Duration
- {/* Filter Submenu - opens as separate submenu */}
-
+ {/* Filter Submenu - multi-select toggles */}
+ 0 ? ` (${activeFilterCount})` : ""}`}
+ >
handleFilterOption("all")}
+ icon={filters.hiddenOnly ? "checkmark.circle.fill" : "eye.slash"}
+ onPress={() => handleFilterToggle("hiddenOnly")}
>
- All Event Types
-
- handleFilterOption("active")}>
- Active Only
+ Hidden Only
handleFilterOption("paid")}
+ icon={filters.paidOnly ? "checkmark.circle.fill" : "dollarsign.circle"}
+ onPress={() => handleFilterToggle("paidOnly")}
>
Paid Events
+ handleFilterToggle("seatedOnly")}
+ >
+ Seated Events
+
+ handleFilterToggle("requiresConfirmationOnly")}
+ >
+ Requires Confirmation
+
+ handleFilterToggle("recurringOnly")}
+ >
+ Recurring
+
+ {activeFilterCount > 0 && (
+
+ Clear All Filters
+
+ )}
@@ -469,6 +488,17 @@ export default function EventTypesIOS() {
description="Try searching with different keywords"
/>
+ ) : filteredEventTypes.length === 0 && activeFilterCount > 0 ? (
+
+
+
) : (
@@ -478,9 +508,7 @@ export default function EventTypesIOS() {
item={item}
index={index}
filteredEventTypes={filteredEventTypes}
- copiedEventTypeId={null}
handleEventTypePress={handleEventTypePress}
- handleEventTypeLongPress={handleEventTypeLongPress}
handleCopyLink={handleCopyLink}
handlePreview={handlePreview}
onEdit={handleEdit}
diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx
index 4fd61d6038..9543eea293 100644
--- a/companion/app/(tabs)/(event-types)/index.tsx
+++ b/companion/app/(tabs)/(event-types)/index.tsx
@@ -4,7 +4,6 @@ import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import {
- ActionSheetIOS,
Alert,
Platform,
RefreshControl,
@@ -37,6 +36,7 @@ import {
useDuplicateEventType,
useEventTypes,
} from "@/hooks";
+import { useEventTypeFilter } from "@/hooks/useEventTypeFilter";
import { CalComAPIService, type EventType } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
@@ -94,18 +94,12 @@ export default function EventTypes() {
// Toast state for web platform
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState("");
- const [copiedEventTypeId, setCopiedEventTypeId] = useState(null);
-
// Function to show toast
- const showToastMessage = (message: string, eventTypeId?: number) => {
+ const showToastMessage = (message: string) => {
setToastMessage(message);
setShowToast(true);
- if (eventTypeId) {
- setCopiedEventTypeId(eventTypeId);
- }
setTimeout(() => {
setShowToast(false);
- setCopiedEventTypeId(null);
}, 2000);
};
@@ -113,18 +107,34 @@ export default function EventTypes() {
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
- // Filter event types based on search query
+ // Event type filter and sort hook
+ const {
+ sortBy,
+ filters,
+ setSortBy,
+ toggleFilter,
+ resetFilters,
+ filteredAndSortedEventTypes,
+ activeFilterCount,
+ } = useEventTypeFilter();
+
+ // Filter event types based on search query and filter/sort options
const filteredEventTypes = useMemo(() => {
- if (searchQuery.trim() === "") {
- return eventTypes;
+ // First apply filter/sort from the hook
+ let filtered = filteredAndSortedEventTypes(eventTypes);
+
+ // Then apply search query filter
+ if (searchQuery.trim() !== "") {
+ const searchLower = searchQuery.toLowerCase();
+ filtered = filtered.filter(
+ (eventType) =>
+ eventType.title.toLowerCase().includes(searchLower) ||
+ eventType.description?.toLowerCase().includes(searchLower)
+ );
}
- const searchLower = searchQuery.toLowerCase();
- return eventTypes.filter(
- (eventType) =>
- eventType.title.toLowerCase().includes(searchLower) ||
- eventType.description?.toLowerCase().includes(searchLower)
- );
- }, [eventTypes, searchQuery]);
+
+ return filtered;
+ }, [eventTypes, searchQuery, filteredAndSortedEventTypes]);
const handleSearch = (query: string) => {
setSearchQuery(query);
@@ -134,53 +144,13 @@ export default function EventTypes() {
handleEdit(eventType);
};
- const handleEventTypeLongPress = (eventType: EventType) => {
- if (Platform.OS === "web") {
- // Show custom modal for web platform
- setSelectedEventType(eventType);
- setShowActionModal(true);
- return;
- }
-
- // Android handles long-press via DropdownMenu in EventTypeListItem.android.tsx
- if (Platform.OS === "android") {
- return;
- }
-
- ActionSheetIOS.showActionSheetWithOptions(
- {
- options: ["Cancel", "Edit", "Duplicate", "Delete"],
- destructiveButtonIndex: 3, // Delete button
- cancelButtonIndex: 0,
- title: eventType.title,
- message: eventType.description ? normalizeMarkdown(eventType.description) : undefined,
- },
- (buttonIndex) => {
- switch (buttonIndex) {
- case 1: // Edit
- handleEdit(eventType);
- break;
- case 2: // Duplicate
- handleDuplicate(eventType);
- break;
- case 3: // Delete
- handleDelete(eventType);
- break;
- default:
- // Cancel - do nothing
- break;
- }
- }
- );
- };
-
const handleCopyLink = async (eventType: EventType) => {
try {
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
await Clipboard.setStringAsync(link);
if (Platform.OS === "web") {
- showToastMessage("Link copied!", eventType.id);
+ showToastMessage("Link copied!");
} else {
Alert.alert("Link Copied", "Event type link copied!");
}
@@ -532,7 +502,7 @@ export default function EventTypes() {
Event Types
@@ -549,7 +519,16 @@ export default function EventTypes() {
{(Platform.OS === "web" || Platform.OS === "android") && (
<>
-
+
-
-
- {filteredEventTypes.map((item, index) => (
-
- ))}
+ {filteredEventTypes.length === 0 && activeFilterCount > 0 ? (
+
+
-
+ ) : (
+
+
+ {filteredEventTypes.map((item, index) => (
+
+ ))}
+
+
+ )}
{/* Create Event Type Modal - Android uses AlertDialog */}
diff --git a/companion/app/(tabs)/index.tsx b/companion/app/(tabs)/index.tsx
new file mode 100644
index 0000000000..d986559187
--- /dev/null
+++ b/companion/app/(tabs)/index.tsx
@@ -0,0 +1,5 @@
+import { Redirect } from "expo-router";
+
+export default function TabsIndex() {
+ return ;
+}
diff --git a/companion/app/add-guests.tsx b/companion/app/add-guests.tsx
index df99d002e1..dbb88a44c6 100644
--- a/companion/app/add-guests.tsx
+++ b/companion/app/add-guests.tsx
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen";
import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
@@ -43,22 +44,26 @@ export default function AddGuests() {
const renderHeaderLeft = useCallback(
() => (
- router.back()} className="px-2 py-2">
-
-
+
+ router.back()} className="px-2 py-2">
+
+
+
),
[router]
);
const renderHeaderRight = useCallback(
() => (
-
-
-
+
+
+
+
+
),
[handleSave, isSaving]
);
diff --git a/companion/app/edit-availability-day.tsx b/companion/app/edit-availability-day.tsx
index 9fd6233da8..852f34f669 100644
--- a/companion/app/edit-availability-day.tsx
+++ b/companion/app/edit-availability-day.tsx
@@ -2,6 +2,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen";
import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen";
import { CalComAPIService, type Schedule } from "@/services/calcom";
@@ -65,14 +66,16 @@ export default function EditAvailabilityDay() {
options={{
title: dayName,
headerRight: () => (
-
- {isSaving ? "Saving..." : "Save"}
-
+
+
+ {isSaving ? "Saving..." : "Save"}
+
+
),
}}
/>
diff --git a/companion/app/edit-availability-name.tsx b/companion/app/edit-availability-name.tsx
index 1f994f39c8..5cd3a8cbb5 100644
--- a/companion/app/edit-availability-name.tsx
+++ b/companion/app/edit-availability-name.tsx
@@ -2,6 +2,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen";
import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen";
import { CalComAPIService, type Schedule } from "@/services/calcom";
@@ -60,14 +61,16 @@ export default function EditAvailabilityName() {
options={{
title: "Edit Name & Timezone",
headerRight: () => (
-
- {isSaving ? "Saving..." : "Save"}
-
+
+
+ {isSaving ? "Saving..." : "Save"}
+
+
),
}}
/>
diff --git a/companion/app/edit-availability-override.tsx b/companion/app/edit-availability-override.tsx
index 6a079c8895..d4bec5f788 100644
--- a/companion/app/edit-availability-override.tsx
+++ b/companion/app/edit-availability-override.tsx
@@ -2,6 +2,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen";
import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen";
import { CalComAPIService, type Schedule } from "@/services/calcom";
@@ -77,14 +78,16 @@ export default function EditAvailabilityOverride() {
options={{
title,
headerRight: () => (
-
- {isSaving ? "Saving..." : "Save"}
-
+
+
+ {isSaving ? "Saving..." : "Save"}
+
+
),
}}
/>
diff --git a/companion/app/edit-location.tsx b/companion/app/edit-location.tsx
index 345de79eda..b16c9713b5 100644
--- a/companion/app/edit-location.tsx
+++ b/companion/app/edit-location.tsx
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen";
import EditLocationScreenComponent from "@/components/screens/EditLocationScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
@@ -43,22 +44,26 @@ export default function EditLocation() {
const renderHeaderLeft = useCallback(
() => (
- router.back()} className="px-2 py-2">
-
-
+
+ router.back()} className="px-2 py-2">
+
+
+
),
[router]
);
const renderHeaderRight = useCallback(
() => (
-
-
-
+
+
+
+
+
),
[handleSave, isSaving]
);
diff --git a/companion/app/mark-no-show.tsx b/companion/app/mark-no-show.tsx
index 641087fa7d..2fc359ce0b 100644
--- a/companion/app/mark-no-show.tsx
+++ b/companion/app/mark-no-show.tsx
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
@@ -62,9 +63,11 @@ export default function MarkNoShow() {
const renderHeaderLeft = useCallback(
() => (
- router.back()} className="px-2 py-2">
-
-
+
+ router.back()} className="px-2 py-2">
+
+
+
),
[router]
);
diff --git a/companion/app/meeting-session-details.tsx b/companion/app/meeting-session-details.tsx
index ef30fb05f8..7a0a437b76 100644
--- a/companion/app/meeting-session-details.tsx
+++ b/companion/app/meeting-session-details.tsx
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { ConferencingSession } from "@/services/types/bookings.types";
@@ -34,9 +35,11 @@ export default function MeetingSessionDetails() {
const renderHeaderLeft = useCallback(
() => (
- router.back()} className="px-2 py-2">
-
-
+
+ router.back()} className="px-2 py-2">
+
+
+
),
[router]
);
diff --git a/companion/app/profile-sheet.tsx b/companion/app/profile-sheet.tsx
index 3e0403741b..1cece9f945 100644
--- a/companion/app/profile-sheet.tsx
+++ b/companion/app/profile-sheet.tsx
@@ -12,6 +12,7 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { useUserProfile } from "@/hooks";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
@@ -116,9 +117,11 @@ export default function ProfileSheet() {
},
headerLeft: () => null,
headerRight: () => (
-
-
-
+
+
+
+
+
),
}}
/>
diff --git a/companion/app/reschedule.tsx b/companion/app/reschedule.tsx
index 6e671bf5c7..90a6f79e59 100644
--- a/companion/app/reschedule.tsx
+++ b/companion/app/reschedule.tsx
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen";
import RescheduleScreenComponent from "@/components/screens/RescheduleScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
@@ -43,22 +44,26 @@ export default function Reschedule() {
const renderHeaderLeft = useCallback(
() => (
- router.back()} className="px-2 py-2">
-
-
+
+ router.back()} className="px-2 py-2">
+
+
+
),
[router]
);
const renderHeaderRight = useCallback(
() => (
-
-
-
+
+
+
+
+
),
[handleSave, isSaving]
);
diff --git a/companion/app/view-recordings.tsx b/companion/app/view-recordings.tsx
index a796e98ff3..3fff6bf752 100644
--- a/companion/app/view-recordings.tsx
+++ b/companion/app/view-recordings.tsx
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
+import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { BookingRecording } from "@/services/types/bookings.types";
@@ -34,9 +35,11 @@ export default function ViewRecordings() {
const renderHeaderLeft = useCallback(
() => (
- router.back()} className="px-2 py-2">
-
-
+
+ router.back()} className="px-2 py-2">
+
+
+
),
[router]
);
diff --git a/companion/components/BookingActionsModal.tsx b/companion/components/BookingActionsModal.tsx
index 2a7aa2c9a8..4ad27dbba9 100644
--- a/companion/components/BookingActionsModal.tsx
+++ b/companion/components/BookingActionsModal.tsx
@@ -66,8 +66,8 @@ function ActionButton({
}: ActionButtonProps) {
if (!visible) return null;
- const iconColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#6B7280";
- const textColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#111827";
+ const iconColor = !enabled ? "#D1D5DB" : isDanger ? "#800020" : "#6B7280";
+ const textColor = !enabled ? "#D1D5DB" : isDanger ? "#800020" : "#111827";
return (
-
{buttonText}
) : null}
diff --git a/companion/components/Header.tsx b/companion/components/Header.tsx
index ae9cd241e8..3d2d5db3bc 100644
--- a/companion/components/Header.tsx
+++ b/companion/components/Header.tsx
@@ -1,13 +1,56 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
-import { ActivityIndicator, Image, Platform, TouchableOpacity, View } from "react-native";
+import { ActivityIndicator, Image, Platform, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, type UserProfile } from "@/services/calcom";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { CalComLogo } from "./CalComLogo";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { AppPressable } from "@/components/AppPressable";
+import type { EventTypeFilters, EventTypeSortOption } from "@/hooks/useEventTypeFilter";
-export function Header() {
+interface FilterOption {
+ key: string;
+ label: string;
+}
+
+interface EventTypeFilterConfig {
+ sortBy: EventTypeSortOption;
+ filters: EventTypeFilters;
+ onSortChange: (sort: EventTypeSortOption) => void;
+ onToggleFilter: (filterKey: keyof EventTypeFilters) => void;
+ onResetFilters: () => void;
+ activeFilterCount: number;
+}
+
+interface HeaderProps {
+ /** Optional: Filter options for dropdown menu (e.g., booking status filter) */
+ filterOptions?: FilterOption[];
+ /** Optional: Currently active filter key */
+ activeFilter?: string;
+ /** Optional: Callback when filter changes */
+ onFilterChange?: (filterKey: string) => void;
+ /** Optional: Event type filter/sort config for Android */
+ eventTypeFilterConfig?: EventTypeFilterConfig;
+}
+
+export function Header({
+ filterOptions,
+ activeFilter,
+ onFilterChange,
+ eventTypeFilterConfig,
+}: HeaderProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const [userProfile, setUserProfile] = useState(null);
@@ -38,6 +81,9 @@ export function Header() {
router.push("/profile-sheet");
};
+ const activeFilterLabel =
+ filterOptions?.find((opt) => opt.key === activeFilter)?.label ?? "Filter";
+
return (
- {/* Right: Icons */}
+ {/* Right: Filter Dropdown + Profile */}
+ {/* Booking status filter dropdown (for bookings page) */}
+ {filterOptions && filterOptions.length > 0 && onFilterChange && (
+
+
+
+
+ {activeFilterLabel}
+
+
+
+
+
+
+ {filterOptions.map((option) => {
+ const isSelected = activeFilter === option.key;
+ // Map filter keys to appropriate icons
+ const getFilterIcon = (key: string) => {
+ switch (key) {
+ case "upcoming":
+ return "calendar-outline";
+ case "unconfirmed":
+ return "help-circle-outline";
+ case "recurring":
+ return "repeat-outline";
+ case "past":
+ return "checkmark-circle-outline";
+ case "cancelled":
+ return "close-circle-outline";
+ default:
+ return "calendar-outline";
+ }
+ };
+
+ return (
+ onFilterChange(option.key)}>
+
+
+
+ {option.label}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Event type filter/sort menu (for event types page - Android and Web/Extension) */}
+ {eventTypeFilterConfig && (Platform.OS === "android" || Platform.OS === "web") && (
+
+
+
+
+ {/* Badge for active filters */}
+ {eventTypeFilterConfig.activeFilterCount > 0 && (
+
+
+ {eventTypeFilterConfig.activeFilterCount}
+
+
+ )}
+
+
+
+
+ {/* Sort by Submenu */}
+
+
+
+
+ Sort by
+
+
+
+ eventTypeFilterConfig.onSortChange("alphabetical")}
+ >
+
+
+
+ Alphabetical
+
+
+
+ eventTypeFilterConfig.onSortChange("newest")}>
+
+
+
+ Newest First
+
+
+
+ eventTypeFilterConfig.onSortChange("duration")}>
+
+
+
+ By Duration
+
+
+
+
+
+
+
+
+ {/* Filter Label */}
+
+
+
+ Filters
+
+
+
+ {/* Filter Toggles (multi-select) */}
+ eventTypeFilterConfig.onToggleFilter("hiddenOnly")}>
+
+
+
+ Hidden Only
+
+
+
+
+ eventTypeFilterConfig.onToggleFilter("paidOnly")}>
+
+
+
+ Paid Events
+
+
+
+
+ eventTypeFilterConfig.onToggleFilter("seatedOnly")}>
+
+
+
+ Seated Events
+
+
+
+
+ eventTypeFilterConfig.onToggleFilter("requiresConfirmationOnly")}
+ >
+
+
+
+ Requires Confirmation
+
+
+
+
+ eventTypeFilterConfig.onToggleFilter("recurringOnly")}
+ >
+
+
+
+ Recurring
+
+
+
+
+ {/* Clear All - only show when filters are active */}
+ {eventTypeFilterConfig.activeFilterCount > 0 && (
+ <>
+
+
+
+
+ Clear All Filters
+
+
+ >
+ )}
+
+
+ )}
+
{/* Profile Picture */}
{loading ? (
diff --git a/companion/components/HeaderButtonWrapper.tsx b/companion/components/HeaderButtonWrapper.tsx
new file mode 100644
index 0000000000..5033f92629
--- /dev/null
+++ b/companion/components/HeaderButtonWrapper.tsx
@@ -0,0 +1,19 @@
+import { Platform, View } from "react-native";
+
+interface HeaderButtonWrapperProps {
+ children: React.ReactNode;
+ side: "left" | "right";
+}
+
+const WEB_HEADER_INSET = 12;
+
+export function HeaderButtonWrapper({ children, side }: HeaderButtonWrapperProps) {
+ if (Platform.OS !== "web") {
+ return <>{children}>;
+ }
+
+ const style =
+ side === "left" ? { marginLeft: WEB_HEADER_INSET } : { marginRight: WEB_HEADER_INSET };
+
+ return {children};
+}
diff --git a/companion/components/availability-list-item/AvailabilityListItem.android.tsx b/companion/components/availability-list-item/AvailabilityListItem.android.tsx
deleted file mode 100644
index 155faed525..0000000000
--- a/companion/components/availability-list-item/AvailabilityListItem.android.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import React from "react";
-import { Pressable, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Text } from "@/components/ui/text";
-import type { AvailabilityListItemProps } from "./AvailabilityListItem";
-import { AvailabilitySlots, ScheduleName, TimeZoneRow } from "./AvailabilityListItemParts";
-
-export const AvailabilityListItem = ({
- item: schedule,
- index: _index,
- handleSchedulePress,
- handleScheduleLongPress: _handleScheduleLongPress,
- setSelectedSchedule: _setSelectedSchedule,
- setShowActionsModal: _setShowActionsModal,
- onDuplicate,
- onDelete,
- onSetAsDefault,
-}: AvailabilityListItemProps) => {
- const insets = useSafeAreaInsets();
-
- const contentInsets = {
- top: insets.top,
- bottom: insets.bottom,
- left: 12,
- right: 12,
- };
-
- // Define dropdown menu actions based on schedule state
- type DropdownAction = {
- label: string;
- icon: keyof typeof Ionicons.glyphMap;
- onPress: () => void;
- variant?: "default" | "destructive";
- };
-
- const scheduleActions: DropdownAction[] = [
- ...(!schedule.isDefault && onSetAsDefault
- ? [
- {
- label: "Set as Default",
- icon: "star-outline" as const,
- onPress: () => onSetAsDefault(schedule),
- variant: "default" as const,
- },
- ]
- : []),
- ...(onDuplicate
- ? [
- {
- label: "Duplicate",
- icon: "copy-outline" as const,
- onPress: () => onDuplicate(schedule),
- variant: "default" as const,
- },
- ]
- : []),
- ...(onDelete
- ? [
- {
- label: "Delete",
- icon: "trash-outline" as const,
- onPress: () => onDelete(schedule),
- variant: "destructive" as const,
- },
- ]
- : []),
- ];
-
- // Find the index where destructive actions start
- const destructiveStartIndex = scheduleActions.findIndex(
- (action) => action.variant === "destructive"
- );
-
- return (
-
-
- handleSchedulePress(schedule)}
- className="mr-4 flex-1"
- android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
- style={{ minWidth: 0 }}
- >
-
-
-
-
-
-
-
- {/* Dropdown Menu */}
-
-
-
-
-
-
-
-
- {scheduleActions.map((action, index) => (
-
- {/* Add separator before destructive actions */}
- {index === destructiveStartIndex && destructiveStartIndex > 0 && (
-
- )}
-
-
-
- {action.label}
-
-
-
- ))}
-
-
-
-
- );
-};
diff --git a/companion/components/availability-list-item/AvailabilityListItem.tsx b/companion/components/availability-list-item/AvailabilityListItem.tsx
index 0ac3bc3207..6e2479c91b 100644
--- a/companion/components/availability-list-item/AvailabilityListItem.tsx
+++ b/companion/components/availability-list-item/AvailabilityListItem.tsx
@@ -1,19 +1,22 @@
-import { TouchableOpacity, View } from "react-native";
-import type { Schedule } from "@/hooks";
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
- AvailabilitySlots,
- ScheduleActionsButton,
- ScheduleName,
- TimeZoneRow,
-} from "./AvailabilityListItemParts";
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Text } from "@/components/ui/text";
+import type { Schedule } from "@/hooks";
+import { AvailabilitySlots, ScheduleName, TimeZoneRow } from "./AvailabilityListItemParts";
export interface AvailabilityListItemProps {
item: Schedule;
index: number;
handleSchedulePress: (schedule: Schedule) => void;
- handleScheduleLongPress: (schedule: Schedule) => void;
- setSelectedSchedule: (schedule: Schedule) => void;
- setShowActionsModal: (show: boolean) => void;
onDuplicate?: (schedule: Schedule) => void;
onDelete?: (schedule: Schedule) => void;
onSetAsDefault?: (schedule: Schedule) => void;
@@ -22,31 +25,116 @@ export interface AvailabilityListItemProps {
export const AvailabilityListItem = ({
item: schedule,
handleSchedulePress,
- handleScheduleLongPress,
- setSelectedSchedule,
- setShowActionsModal,
+ onDuplicate,
+ onDelete,
+ onSetAsDefault,
}: AvailabilityListItemProps) => {
+ const insets = useSafeAreaInsets();
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
+
+ type DropdownAction = {
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ variant?: "default" | "destructive";
+ };
+
+ const scheduleActions: DropdownAction[] = [
+ ...(!schedule.isDefault && onSetAsDefault
+ ? [
+ {
+ label: "Set as Default",
+ icon: "star-outline" as const,
+ onPress: () => onSetAsDefault(schedule),
+ variant: "default" as const,
+ },
+ ]
+ : []),
+ ...(onDuplicate
+ ? [
+ {
+ label: "Duplicate",
+ icon: "copy-outline" as const,
+ onPress: () => onDuplicate(schedule),
+ variant: "default" as const,
+ },
+ ]
+ : []),
+ ...(onDelete
+ ? [
+ {
+ label: "Delete",
+ icon: "trash-outline" as const,
+ onPress: () => onDelete(schedule),
+ variant: "destructive" as const,
+ },
+ ]
+ : []),
+ ];
+
+ const destructiveStartIndex = scheduleActions.findIndex(
+ (action) => action.variant === "destructive"
+ );
+
return (
- handleSchedulePress(schedule)}
- onLongPress={() => handleScheduleLongPress(schedule)}
- style={{ paddingHorizontal: 16, paddingVertical: 16 }}
- >
-
-
-
-
-
-
-
-
-
+
+
+ handleSchedulePress(schedule)}
+ className="mr-4 flex-1"
+ android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
+ style={{ minWidth: 0 }}
+ >
+
+
+
+
+
+
+
+ {scheduleActions.length > 0 && (
+
+
+
+
+
+
+
+
+ {scheduleActions.map((action, index) => (
+
+ {index === destructiveStartIndex && destructiveStartIndex > 0 && (
+
+ )}
+
+
+
+ {action.label}
+
+
+
+ ))}
+
+
+ )}
-
+
);
};
diff --git a/companion/components/booking-list-item/BookingListItem.android.tsx b/companion/components/booking-list-item/BookingListItem.android.tsx
deleted file mode 100644
index c52a67629e..0000000000
--- a/companion/components/booking-list-item/BookingListItem.android.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import React from "react";
-import { Pressable, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Text } from "@/components/ui/text";
-import { getBookingActions } from "@/utils/booking-actions";
-import {
- BadgesRow,
- BookingDescription,
- BookingTitle,
- ConfirmRejectButtons,
- HostAndAttendees,
- MeetingLink,
- TimeAndDateRow,
-} from "./BookingListItemParts";
-import type { BookingListItemProps } from "./types";
-import { useBookingListItemData } from "./useBookingListItemData";
-
-export const BookingListItem: React.FC = ({
- booking,
- userEmail,
- isConfirming,
- isDeclining,
- onPress,
- onLongPress: _onLongPress,
- onConfirm,
- onReject,
- onActionsPress: _onActionsPress,
- onReschedule,
- onEditLocation,
- onAddGuests,
- onViewRecordings,
- onMeetingSessionDetails,
- onMarkNoShow,
- onReportBooking,
- onCancelBooking,
-}) => {
- const {
- isUpcoming,
- isPending,
- isCancelled,
- isRejected,
- hostAndAttendeesDisplay,
- meetingInfo,
- hasNoShowAttendee,
- formattedDate,
- formattedTimeRange,
- } = useBookingListItemData(booking, userEmail);
-
- const insets = useSafeAreaInsets();
-
- const contentInsets = {
- top: insets.top,
- bottom: insets.bottom,
- left: 12,
- right: 12,
- };
-
- // Use centralized action gating for consistency
- const actions = React.useMemo(() => {
- return getBookingActions({
- booking,
- eventType: undefined,
- currentUserId: undefined,
- currentUserEmail: userEmail,
- isOnline: true,
- });
- }, [booking, userEmail]);
-
- // Define dropdown menu actions based on booking state
- type DropdownAction = {
- label: string;
- icon: keyof typeof Ionicons.glyphMap;
- onPress: () => void;
- variant?: "default" | "destructive";
- };
-
- const allActions: (DropdownAction & { visible: boolean })[] = [
- // Edit Event Section
- {
- label: "Reschedule Booking",
- icon: "calendar-outline",
- onPress: () => onReschedule?.(booking),
- variant: "default" as const,
- visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
- },
- {
- label: "Edit Location",
- icon: "location-outline",
- onPress: () => onEditLocation?.(booking),
- variant: "default" as const,
- visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
- },
- {
- label: "Add Guests",
- icon: "person-add-outline",
- onPress: () => onAddGuests?.(booking),
- variant: "default" as const,
- visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
- },
- // After Event Section
- {
- label: "View Recordings",
- icon: "videocam-outline",
- onPress: () => onViewRecordings?.(booking),
- variant: "default" as const,
- visible:
- actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
- },
- {
- label: "Meeting Session Details",
- icon: "information-circle-outline",
- onPress: () => onMeetingSessionDetails?.(booking),
- variant: "default" as const,
- visible:
- actions.meetingSessionDetails.visible &&
- actions.meetingSessionDetails.enabled &&
- !!onMeetingSessionDetails,
- },
- {
- label: "Mark as No-Show",
- icon: "eye-off-outline",
- onPress: () => onMarkNoShow?.(booking),
- variant: "default" as const,
- visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
- },
- // Other Actions
- {
- label: "Report Booking",
- icon: "flag-outline",
- onPress: () => onReportBooking?.(booking),
- variant: "destructive" as const,
- visible: !!onReportBooking,
- },
- {
- label: "Cancel Event",
- icon: "close-circle-outline",
- onPress: () => onCancelBooking?.(booking),
- variant: "destructive" as const,
- visible: isUpcoming && !isCancelled && !!onCancelBooking,
- },
- ];
-
- const visibleActions = allActions.filter((action) => action.visible);
-
- // Find the index where destructive actions start
- const destructiveStartIndex = visibleActions.findIndex(
- (action) => action.variant === "destructive"
- );
-
- return (
-
- onPress(booking)}
- style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
- className="active:bg-cal-bg-secondary"
- android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
- >
-
-
-
-
-
-
-
-
-
-
- {/* Dropdown Menu - only show when there are visible actions */}
- {visibleActions.length > 0 && (
-
-
-
-
-
-
-
-
- {visibleActions.map((action, index) => (
-
- {/* Add separator before destructive actions */}
- {index === destructiveStartIndex && destructiveStartIndex > 0 && (
-
- )}
-
-
-
- {action.label}
-
-
-
- ))}
-
-
- )}
-
-
- );
-};
diff --git a/companion/components/booking-list-item/BookingListItem.ios.tsx b/companion/components/booking-list-item/BookingListItem.ios.tsx
index 9aec245eda..7773f29552 100644
--- a/companion/components/booking-list-item/BookingListItem.ios.tsx
+++ b/companion/components/booking-list-item/BookingListItem.ios.tsx
@@ -25,7 +25,6 @@ export const BookingListItem: React.FC = ({
onPress,
onConfirm,
onReject,
- onActionsPress: _onActionsPress,
onReschedule,
onEditLocation,
onAddGuests,
diff --git a/companion/components/booking-list-item/BookingListItem.tsx b/companion/components/booking-list-item/BookingListItem.tsx
index 95acc66a4d..5271db5596 100644
--- a/companion/components/booking-list-item/BookingListItem.tsx
+++ b/companion/components/booking-list-item/BookingListItem.tsx
@@ -1,6 +1,16 @@
import { Ionicons } from "@expo/vector-icons";
-import type React from "react";
-import { TouchableOpacity, View } from "react-native";
+import React from "react";
+import { Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Text } from "@/components/ui/text";
+import { getBookingActions } from "@/utils/booking-actions";
import {
BadgesRow,
BookingDescription,
@@ -19,12 +29,19 @@ export const BookingListItem: React.FC = ({
isConfirming,
isDeclining,
onPress,
- onLongPress,
onConfirm,
onReject,
- onActionsPress,
+ onReschedule,
+ onEditLocation,
+ onAddGuests,
+ onViewRecordings,
+ onMeetingSessionDetails,
+ onMarkNoShow,
+ onReportBooking,
+ onCancelBooking,
}) => {
const {
+ isUpcoming,
isPending,
isCancelled,
isRejected,
@@ -35,13 +52,108 @@ export const BookingListItem: React.FC = ({
formattedTimeRange,
} = useBookingListItemData(booking, userEmail);
+ const insets = useSafeAreaInsets();
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
+
+ const actions = React.useMemo(() => {
+ return getBookingActions({
+ booking,
+ eventType: undefined,
+ currentUserId: undefined,
+ currentUserEmail: userEmail,
+ isOnline: true,
+ });
+ }, [booking, userEmail]);
+
+ type DropdownAction = {
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ variant?: "default" | "destructive";
+ };
+
+ const allActions: (DropdownAction & { visible: boolean })[] = [
+ {
+ label: "Reschedule Booking",
+ icon: "calendar-outline",
+ onPress: () => onReschedule?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isRejected && !isPending && !!onReschedule,
+ },
+ {
+ label: "Edit Location",
+ icon: "location-outline",
+ onPress: () => onEditLocation?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isRejected && !isPending && !!onEditLocation,
+ },
+ {
+ label: "Add Guests",
+ icon: "person-add-outline",
+ onPress: () => onAddGuests?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isRejected && !isPending && !!onAddGuests,
+ },
+ {
+ label: "View Recordings",
+ icon: "videocam-outline",
+ onPress: () => onViewRecordings?.(booking),
+ variant: "default" as const,
+ visible:
+ actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
+ },
+ {
+ label: "Meeting Session Details",
+ icon: "information-circle-outline",
+ onPress: () => onMeetingSessionDetails?.(booking),
+ variant: "default" as const,
+ visible:
+ actions.meetingSessionDetails.visible &&
+ actions.meetingSessionDetails.enabled &&
+ !!onMeetingSessionDetails,
+ },
+ {
+ label: "Mark as No-Show",
+ icon: "eye-off-outline",
+ onPress: () => onMarkNoShow?.(booking),
+ variant: "default" as const,
+ visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
+ },
+ {
+ label: "Report Booking",
+ icon: "flag-outline",
+ onPress: () => onReportBooking?.(booking),
+ variant: "destructive" as const,
+ visible: !!onReportBooking,
+ },
+ {
+ label: "Cancel Event",
+ icon: "close-circle-outline",
+ onPress: () => onCancelBooking?.(booking),
+ variant: "destructive" as const,
+ visible: isUpcoming && !isCancelled && !isRejected && !!onCancelBooking,
+ },
+ ];
+
+ const visibleActions = allActions.filter((action) => action.visible);
+
+ const destructiveStartIndex = visibleActions.findIndex(
+ (action) => action.variant === "destructive"
+ );
+
return (
- onPress(booking)}
- onLongPress={() => onLongPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
+ className="active:bg-cal-bg-secondary"
+ android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
>
@@ -52,7 +164,7 @@ export const BookingListItem: React.FC = ({
hasNoShowAttendee={hasNoShowAttendee}
/>
-
+
= ({
onConfirm={onConfirm}
onReject={onReject}
/>
- {
- e.stopPropagation();
- onActionsPress(booking);
- }}
- >
-
-
+
+ {visibleActions.length > 0 && (
+
+
+
+
+
+
+
+
+ {visibleActions.map((action, index) => (
+
+ {index === destructiveStartIndex && destructiveStartIndex > 0 && (
+
+ )}
+
+
+
+ {action.label}
+
+
+
+ ))}
+
+
+ )}
);
diff --git a/companion/components/booking-list-item/BookingListItemParts.tsx b/companion/components/booking-list-item/BookingListItemParts.tsx
index 26032091d5..e23bff3413 100644
--- a/companion/components/booking-list-item/BookingListItemParts.tsx
+++ b/companion/components/booking-list-item/BookingListItemParts.tsx
@@ -80,7 +80,7 @@ export function HostAndAttendees({
{hostAndAttendeesDisplay}
{hasNoShowAttendee && (
-
+
No-show
diff --git a/companion/components/booking-list-item/RecurringBookingListItem.ios.tsx b/companion/components/booking-list-item/RecurringBookingListItem.ios.tsx
new file mode 100644
index 0000000000..5803742913
--- /dev/null
+++ b/companion/components/booking-list-item/RecurringBookingListItem.ios.tsx
@@ -0,0 +1,350 @@
+import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
+import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Pressable, Text, TouchableOpacity, View, Linking } from "react-native";
+import type { SFSymbols7_0 } from "sf-symbols-typescript";
+import type { Booking } from "@/services/calcom";
+import type { RecurringBookingGroup } from "@/utils/bookings-utils";
+import { formatDate, formatTime, getHostAndAttendeesDisplay } from "@/utils/bookings-utils";
+import { getMeetingInfo } from "@/utils/meetings-utils";
+import { SvgImage } from "@/components/SvgImage";
+import { showErrorAlert } from "@/utils/alerts";
+import { getBookingActions } from "@/utils/booking-actions";
+
+export interface RecurringBookingListItemProps {
+ group: RecurringBookingGroup;
+ userEmail?: string;
+ isConfirmingAll?: boolean;
+ isDecliningAll?: boolean;
+ isCancellingAll?: boolean;
+ onPress: (group: RecurringBookingGroup) => void;
+ onLongPress?: (group: RecurringBookingGroup) => void;
+ onConfirmAll?: (group: RecurringBookingGroup) => void;
+ onRejectAll?: (group: RecurringBookingGroup) => void;
+ onCancelAllRemaining?: (group: RecurringBookingGroup) => void;
+ // Individual booking actions
+ onReschedule?: (booking: Booking) => void;
+ onEditLocation?: (booking: Booking) => void;
+ onAddGuests?: (booking: Booking) => void;
+ onViewRecordings?: (booking: Booking) => void;
+ onMeetingSessionDetails?: (booking: Booking) => void;
+ onMarkNoShow?: (booking: Booking) => void;
+ onReportBooking?: (booking: Booking) => void;
+ onCancelBooking?: (booking: Booking) => void;
+}
+
+export const RecurringBookingListItem: React.FC = ({
+ group,
+ userEmail,
+ isConfirmingAll = false,
+ isDecliningAll = false,
+ isCancellingAll = false,
+ onPress,
+ onLongPress: _onLongPress, // Not used on iOS - native ContextMenu handles long-press
+ onConfirmAll,
+ onRejectAll,
+ onCancelAllRemaining,
+ onReschedule,
+ onEditLocation,
+ onAddGuests,
+ onViewRecordings,
+ onMeetingSessionDetails,
+ onMarkNoShow,
+ onReportBooking,
+ onCancelBooking,
+}) => {
+ const booking = group.firstUpcoming;
+ const startTime = booking.start || booking.startTime || "";
+ const endTime = booking.end || booking.endTime || "";
+ const isUpcoming = new Date(endTime) >= new Date();
+ const isCancelled = booking.status?.toLowerCase() === "cancelled";
+ const isRejected = booking.status?.toLowerCase() === "rejected";
+ const isPending = booking.status?.toLowerCase() === "pending" || booking.requiresConfirmation;
+
+ const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
+ const meetingInfo = getMeetingInfo(booking.location);
+ const formattedDate = formatDate(startTime, isUpcoming);
+ const formattedTimeRange = `${formatTime(startTime)} - ${formatTime(endTime)}`;
+
+ const isProcessing = isConfirmingAll || isDecliningAll || isCancellingAll;
+
+ // Use centralized action gating for consistency
+ const actions = React.useMemo(() => {
+ return getBookingActions({
+ booking,
+ eventType: undefined,
+ currentUserId: undefined,
+ currentUserEmail: userEmail,
+ isOnline: true,
+ });
+ }, [booking, userEmail]);
+
+ // Define context menu actions based on booking state
+ type ContextMenuAction = {
+ label: string;
+ icon: SFSymbols7_0;
+ onPress: () => void;
+ role: "default" | "destructive";
+ };
+
+ const allActions: (ContextMenuAction & { visible: boolean })[] = [
+ // Edit Event Section
+ {
+ label: "Reschedule Booking",
+ icon: "calendar" as const,
+ onPress: () => onReschedule?.(booking),
+ role: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
+ },
+ {
+ label: "Edit Location",
+ icon: "location" as const,
+ onPress: () => onEditLocation?.(booking),
+ role: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
+ },
+ {
+ label: "Add Guests",
+ icon: "person.badge.plus" as const,
+ onPress: () => onAddGuests?.(booking),
+ role: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
+ },
+ // After Event Section
+ {
+ label: "View Recordings",
+ icon: "video" as const,
+ onPress: () => onViewRecordings?.(booking),
+ role: "default" as const,
+ visible:
+ actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
+ },
+ {
+ label: "Meeting Session Details",
+ icon: "info.circle" as const,
+ onPress: () => onMeetingSessionDetails?.(booking),
+ role: "default" as const,
+ visible:
+ actions.meetingSessionDetails.visible &&
+ actions.meetingSessionDetails.enabled &&
+ !!onMeetingSessionDetails,
+ },
+ {
+ label: "Mark as No-Show",
+ icon: "eye.slash" as const,
+ onPress: () => onMarkNoShow?.(booking),
+ role: "default" as const,
+ visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
+ },
+ // Other Actions
+ {
+ label: "Report Booking",
+ icon: "flag" as const,
+ onPress: () => onReportBooking?.(booking),
+ role: "destructive" as const,
+ visible: !!onReportBooking,
+ },
+ {
+ label: "Cancel Event",
+ icon: "xmark.circle" as const,
+ onPress: () => onCancelBooking?.(booking),
+ role: "destructive" as const,
+ visible: isUpcoming && !isCancelled && !!onCancelBooking,
+ },
+ ];
+
+ const contextMenuActions: ContextMenuAction[] = allActions
+ .filter((action) => action.visible)
+ .map(({ label, icon, onPress, role }) => ({ label, icon, onPress, role }));
+
+ return (
+
+ onPress(group)}
+ style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
+ className="active:bg-cal-bg-secondary"
+ >
+ {/* Date and Time */}
+
+ {formattedDate}
+ {formattedTimeRange}
+
+
+ {/* Badges Row */}
+
+ {/* Recurring Badge */}
+
+
+ {group.remainingCount} {group.remainingCount === 1 ? "event" : "events"} remaining
+
+
+
+ {/* Unconfirmed Badge */}
+ {group.hasUnconfirmed && (
+
+ Unconfirmed
+
+ )}
+
+
+ {/* Recurrence Pattern Text (for unconfirmed recurring) */}
+ {group.hasUnconfirmed && group.recurrenceText && (
+ {group.recurrenceText}
+ )}
+
+ {/* Title */}
+
+ {booking.title}
+
+
+ {/* Description */}
+ {booking.description ? (
+
+ "{booking.description}"
+
+ ) : null}
+
+ {/* Host and Attendees */}
+ {hostAndAttendeesDisplay ? (
+
+ {hostAndAttendeesDisplay}
+
+ ) : null}
+
+ {/* Meeting Link */}
+ {meetingInfo ? (
+
+ {
+ e.stopPropagation();
+ try {
+ await Linking.openURL(meetingInfo.cleanUrl);
+ } catch {
+ showErrorAlert("Error", "Failed to open meeting link. Please try again.");
+ }
+ }}
+ >
+ {meetingInfo.iconUrl ? (
+
+ ) : (
+
+ )}
+ {meetingInfo.label}
+
+
+ ) : null}
+
+
+ {/* Action Buttons */}
+
+ {/* Confirm All / Reject All for unconfirmed recurring */}
+ {group.hasUnconfirmed && onRejectAll && (
+ {
+ e.stopPropagation();
+ onRejectAll(group);
+ }}
+ >
+
+ Reject all
+
+ )}
+
+ {group.hasUnconfirmed && onConfirmAll && (
+ {
+ e.stopPropagation();
+ onConfirmAll(group);
+ }}
+ >
+
+ Confirm all
+
+ )}
+
+ {/* Cancel All Remaining */}
+ {onCancelAllRemaining && group.remainingCount > 0 && !group.hasUnconfirmed && (
+ {
+ e.stopPropagation();
+ onCancelAllRemaining(group);
+ }}
+ >
+
+
+ Cancel all remaining
+
+
+ )}
+
+ {/* iOS Context Menu */}
+
+
+
+ {contextMenuActions.map((action) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/companion/components/booking-list-item/RecurringBookingListItem.tsx b/companion/components/booking-list-item/RecurringBookingListItem.tsx
new file mode 100644
index 0000000000..904277e87b
--- /dev/null
+++ b/companion/components/booking-list-item/RecurringBookingListItem.tsx
@@ -0,0 +1,365 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Pressable, Text, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Text as UIText } from "@/components/ui/text";
+import type { Booking } from "@/services/calcom";
+import type { RecurringBookingGroup } from "@/utils/bookings-utils";
+import { formatDate, formatTime, getHostAndAttendeesDisplay } from "@/utils/bookings-utils";
+import { getMeetingInfo } from "@/utils/meetings-utils";
+import { SvgImage } from "@/components/SvgImage";
+import { showErrorAlert } from "@/utils/alerts";
+import { Linking } from "react-native";
+import { getBookingActions } from "@/utils/booking-actions";
+
+export interface RecurringBookingListItemProps {
+ group: RecurringBookingGroup;
+ userEmail?: string;
+ isConfirmingAll?: boolean;
+ isDecliningAll?: boolean;
+ isCancellingAll?: boolean;
+ onPress: (group: RecurringBookingGroup) => void;
+ onConfirmAll?: (group: RecurringBookingGroup) => void;
+ onRejectAll?: (group: RecurringBookingGroup) => void;
+ onCancelAllRemaining?: (group: RecurringBookingGroup) => void;
+ onReschedule?: (booking: Booking) => void;
+ onEditLocation?: (booking: Booking) => void;
+ onAddGuests?: (booking: Booking) => void;
+ onViewRecordings?: (booking: Booking) => void;
+ onMeetingSessionDetails?: (booking: Booking) => void;
+ onMarkNoShow?: (booking: Booking) => void;
+ onReportBooking?: (booking: Booking) => void;
+ onCancelBooking?: (booking: Booking) => void;
+}
+
+export const RecurringBookingListItem: React.FC = ({
+ group,
+ userEmail,
+ isConfirmingAll = false,
+ isDecliningAll = false,
+ isCancellingAll = false,
+ onPress,
+ onConfirmAll,
+ onRejectAll,
+ onCancelAllRemaining,
+ onReschedule,
+ onEditLocation,
+ onAddGuests,
+ onViewRecordings,
+ onMeetingSessionDetails,
+ onMarkNoShow,
+ onReportBooking,
+ onCancelBooking,
+}) => {
+ const booking = group.firstUpcoming;
+ const startTime = booking.start || booking.startTime || "";
+ const endTime = booking.end || booking.endTime || "";
+ const isUpcoming = new Date(endTime) >= new Date();
+ const isCancelled = booking.status?.toLowerCase() === "cancelled";
+ const isRejected = booking.status?.toLowerCase() === "rejected";
+ const isPending = booking.status?.toLowerCase() === "pending" || booking.requiresConfirmation;
+
+ const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
+ const meetingInfo = getMeetingInfo(booking.location);
+ const formattedDate = formatDate(startTime, isUpcoming);
+ const formattedTimeRange = `${formatTime(startTime)} - ${formatTime(endTime)}`;
+
+ const isProcessing = isConfirmingAll || isDecliningAll || isCancellingAll;
+
+ const insets = useSafeAreaInsets();
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
+
+ const actions = React.useMemo(() => {
+ return getBookingActions({
+ booking,
+ eventType: undefined,
+ currentUserId: undefined,
+ currentUserEmail: userEmail,
+ isOnline: true,
+ });
+ }, [booking, userEmail]);
+
+ type DropdownAction = {
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ variant?: "default" | "destructive";
+ };
+
+ const allActions: (DropdownAction & { visible: boolean })[] = [
+ {
+ label: "Reschedule Booking",
+ icon: "calendar-outline",
+ onPress: () => onReschedule?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
+ },
+ {
+ label: "Edit Location",
+ icon: "location-outline",
+ onPress: () => onEditLocation?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
+ },
+ {
+ label: "Add Guests",
+ icon: "person-add-outline",
+ onPress: () => onAddGuests?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
+ },
+ {
+ label: "View Recordings",
+ icon: "videocam-outline",
+ onPress: () => onViewRecordings?.(booking),
+ variant: "default" as const,
+ visible:
+ actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
+ },
+ {
+ label: "Meeting Session Details",
+ icon: "information-circle-outline",
+ onPress: () => onMeetingSessionDetails?.(booking),
+ variant: "default" as const,
+ visible:
+ actions.meetingSessionDetails.visible &&
+ actions.meetingSessionDetails.enabled &&
+ !!onMeetingSessionDetails,
+ },
+ {
+ label: "Mark as No-Show",
+ icon: "eye-off-outline",
+ onPress: () => onMarkNoShow?.(booking),
+ variant: "default" as const,
+ visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
+ },
+ {
+ label: "Report Booking",
+ icon: "flag-outline",
+ onPress: () => onReportBooking?.(booking),
+ variant: "destructive" as const,
+ visible: !!onReportBooking,
+ },
+ {
+ label: "Cancel Event",
+ icon: "close-circle-outline",
+ onPress: () => onCancelBooking?.(booking),
+ variant: "destructive" as const,
+ visible: isUpcoming && !isCancelled && !!onCancelBooking,
+ },
+ ];
+
+ const visibleActions = allActions.filter((action) => action.visible);
+
+ const destructiveStartIndex = visibleActions.findIndex(
+ (action) => action.variant === "destructive"
+ );
+
+ return (
+
+ onPress(group)}
+ style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
+ className="active:bg-cal-bg-secondary"
+ android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
+ >
+ {/* Date and Time */}
+
+ {formattedDate}
+ {formattedTimeRange}
+
+
+ {/* Badges Row */}
+
+ {/* Recurring Badge */}
+
+
+ {group.remainingCount} {group.remainingCount === 1 ? "event" : "events"} remaining
+
+
+
+ {/* Unconfirmed Badge */}
+ {group.hasUnconfirmed && (
+
+ Unconfirmed
+
+ )}
+
+
+ {/* Recurrence Pattern Text (for unconfirmed recurring) */}
+ {group.hasUnconfirmed && group.recurrenceText && (
+ {group.recurrenceText}
+ )}
+
+ {/* Title */}
+
+ {booking.title}
+
+
+ {/* Description */}
+ {booking.description ? (
+
+ "{booking.description}"
+
+ ) : null}
+
+ {/* Host and Attendees */}
+ {hostAndAttendeesDisplay ? (
+
+ {hostAndAttendeesDisplay}
+
+ ) : null}
+
+ {/* Meeting Link */}
+ {meetingInfo ? (
+
+ {
+ e.stopPropagation();
+ try {
+ await Linking.openURL(meetingInfo.cleanUrl);
+ } catch {
+ showErrorAlert("Error", "Failed to open meeting link. Please try again.");
+ }
+ }}
+ >
+ {meetingInfo.iconUrl ? (
+
+ ) : (
+
+ )}
+ {meetingInfo.label}
+
+
+ ) : null}
+
+
+ {/* Action Buttons */}
+
+ {/* Confirm All / Reject All for unconfirmed recurring */}
+ {group.hasUnconfirmed && onRejectAll && (
+ {
+ e.stopPropagation();
+ onRejectAll(group);
+ }}
+ >
+
+ Reject all
+
+ )}
+
+ {group.hasUnconfirmed && onConfirmAll && (
+ {
+ e.stopPropagation();
+ onConfirmAll(group);
+ }}
+ >
+
+ Confirm all
+
+ )}
+
+ {/* Cancel All Remaining */}
+ {onCancelAllRemaining && group.remainingCount > 0 && !group.hasUnconfirmed && (
+ {
+ e.stopPropagation();
+ onCancelAllRemaining(group);
+ }}
+ >
+
+
+ Cancel all remaining
+
+
+ )}
+
+ {/* Dropdown Menu - only show when there are visible actions */}
+ {visibleActions.length > 0 && (
+
+
+
+
+
+
+
+
+ {visibleActions.map((action, index) => (
+
+ {index === destructiveStartIndex && destructiveStartIndex > 0 && (
+
+ )}
+
+
+
+ {action.label}
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/companion/components/booking-list-item/types.ts b/companion/components/booking-list-item/types.ts
index f1abd3d1db..d899c1bfda 100644
--- a/companion/components/booking-list-item/types.ts
+++ b/companion/components/booking-list-item/types.ts
@@ -6,11 +6,8 @@ export interface BookingListItemProps {
isConfirming: boolean;
isDeclining: boolean;
onPress: (booking: Booking) => void;
- onLongPress: (booking: Booking) => void;
onConfirm: (booking: Booking) => void;
onReject: (booking: Booking) => void;
- onActionsPress: (booking: Booking) => void;
- // Additional action handlers for context menu (iOS)
onReschedule?: (booking: Booking) => void;
onEditLocation?: (booking: Booking) => void;
onAddGuests?: (booking: Booking) => void;
diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx
index a16572c903..eb84318110 100644
--- a/companion/components/booking-list-screen/BookingListScreen.tsx
+++ b/companion/components/booking-list-screen/BookingListScreen.tsx
@@ -12,6 +12,7 @@ import {
View,
} from "react-native";
import { BookingListItem } from "@/components/booking-list-item/BookingListItem";
+import { RecurringBookingListItem } from "@/components/booking-list-item/RecurringBookingListItem";
import { BookingModals } from "@/components/booking-modals/BookingModals";
import { EmptyScreen } from "@/components/EmptyScreen";
import { LoadingSpinner } from "@/components/LoadingSpinner";
@@ -37,11 +38,12 @@ import {
useRescheduleBooking,
} from "@/hooks";
import type { Booking, EventType } from "@/services/calcom";
-import type { ListItem } from "@/utils/bookings-utils";
+import type { ListItem, RecurringBookingGroup } from "@/utils/bookings-utils";
import {
filterByEventType,
getEmptyStateContent,
groupBookingsByMonth,
+ groupRecurringBookings,
searchBookings,
} from "@/utils/bookings-utils";
import { offlineAwareRefresh } from "@/utils/network";
@@ -126,7 +128,6 @@ export const BookingListScreen: React.FC = ({
handleSubmitCancel,
handleCloseCancelModal,
selectedBooking,
- setSelectedBooking,
handleBookingPress,
handleCancelBooking,
handleInlineConfirm,
@@ -263,7 +264,302 @@ export const BookingListScreen: React.FC = ({
return filtered;
}, [bookings, searchQuery, selectedEventTypeId]);
- const [showBookingActionsModal, setShowBookingActionsModal] = React.useState(false);
+ // Generate list items based on filter type
+ const listItems = useMemo(() => {
+ if (activeFilter === "recurring") {
+ // For recurring filter, group bookings by recurringBookingUid
+ const groups = groupRecurringBookings(filteredBookings);
+ return groups.map((group) => ({
+ type: "recurringGroup" as const,
+ group,
+ key: `recurring-${group.recurringBookingUid}`,
+ }));
+ }
+
+ if (activeFilter === "unconfirmed") {
+ // For unconfirmed filter, group recurring bookings but keep non-recurring separate
+ const recurringBookings: Booking[] = [];
+ const nonRecurringBookings: Booking[] = [];
+
+ filteredBookings.forEach((booking) => {
+ if (booking.recurringBookingUid) {
+ recurringBookings.push(booking);
+ } else {
+ nonRecurringBookings.push(booking);
+ }
+ });
+
+ // Group recurring bookings
+ const recurringGroups = groupRecurringBookings(recurringBookings);
+ const recurringItems: ListItem[] = recurringGroups.map((group) => ({
+ type: "recurringGroup" as const,
+ group,
+ key: `recurring-${group.recurringBookingUid}`,
+ }));
+
+ // Keep non-recurring bookings with month grouping
+ const nonRecurringItems = groupBookingsByMonth(nonRecurringBookings);
+
+ // Combine: recurring groups first, then non-recurring
+ return [...recurringItems, ...nonRecurringItems];
+ }
+
+ // For other filters, use month grouping
+ return groupBookingsByMonth(filteredBookings);
+ }, [filteredBookings, activeFilter]);
+
+ // State for bulk action loading
+ const [isCancellingAll, setIsCancellingAll] = React.useState(false);
+ const [isConfirmingAll, setIsConfirmingAll] = React.useState(false);
+ const [isDecliningAll, setIsDecliningAll] = React.useState(false);
+
+ // Android dialog state for Cancel All
+ const [showCancelAllDialog, setShowCancelAllDialog] = React.useState(false);
+ const [cancelAllGroup, setCancelAllGroup] = React.useState(null);
+ const [cancelAllReason, setCancelAllReason] = React.useState("");
+
+ // Android dialog state for Reject All
+ const [showRejectAllDialog, setShowRejectAllDialog] = React.useState(false);
+ const [rejectAllGroup, setRejectAllGroup] = React.useState(null);
+ const [rejectAllReason, setRejectAllReason] = React.useState("");
+
+ // Handle recurring group press - navigate to first upcoming booking
+ const handleRecurringGroupPress = React.useCallback(
+ (group: RecurringBookingGroup) => {
+ handleBookingPress(group.firstUpcoming);
+ },
+ [handleBookingPress]
+ );
+
+ // Cancel all remaining bookings in a recurring series
+ const handleCancelAllRemaining = React.useCallback(
+ async (group: RecurringBookingGroup) => {
+ // For Android, open dialog with reason input
+ if (Platform.OS === "android") {
+ setCancelAllGroup(group);
+ setCancelAllReason("");
+ setShowCancelAllDialog(true);
+ return;
+ }
+
+ // For iOS, use Alert.prompt
+ Alert.alert(
+ "Cancel All Remaining",
+ `Are you sure you want to cancel all ${group.remainingCount} remaining bookings in this series?`,
+ [
+ { text: "No", style: "cancel" },
+ {
+ text: "Yes, Cancel All",
+ style: "destructive",
+ onPress: () => {
+ Alert.prompt(
+ "Cancellation Reason",
+ "Please provide a reason for cancelling these bookings:",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Cancel All",
+ style: "destructive",
+ onPress: (reason?: string) => {
+ const cancellationReason = reason?.trim() || "Cancelled all remaining";
+ setIsCancellingAll(true);
+ cancelBookingMutation(
+ {
+ uid: group.recurringBookingUid,
+ reason: cancellationReason,
+ },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "All remaining bookings have been cancelled.");
+ setIsCancellingAll(false);
+ },
+ onError: () => {
+ Alert.alert("Error", "Failed to cancel bookings. Please try again.");
+ setIsCancellingAll(false);
+ },
+ }
+ );
+ },
+ },
+ ],
+ "plain-text",
+ "",
+ "default"
+ );
+ },
+ },
+ ]
+ );
+ },
+ [cancelBookingMutation]
+ );
+
+ // Confirm all unconfirmed bookings in a recurring series
+ const handleConfirmAll = React.useCallback(
+ async (group: RecurringBookingGroup) => {
+ const unconfirmedBookings = group.bookings.filter(
+ (b) =>
+ b.status?.toLowerCase() === "pending" ||
+ b.status?.toLowerCase() === "requires_confirmation" ||
+ b.requiresConfirmation
+ );
+
+ if (unconfirmedBookings.length === 0) {
+ Alert.alert("Info", "No unconfirmed bookings to confirm.");
+ return;
+ }
+
+ Alert.alert(
+ "Confirm All",
+ `Are you sure you want to confirm ${unconfirmedBookings.length} unconfirmed bookings?`,
+ [
+ { text: "No", style: "cancel" },
+ {
+ text: "Yes, Confirm All",
+ onPress: async () => {
+ setIsConfirmingAll(true);
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const booking of unconfirmedBookings) {
+ try {
+ const success = await new Promise((resolve, _reject) => {
+ confirmBookingMutation(
+ { uid: booking.uid },
+ {
+ onSuccess: () => {
+ resolve(true);
+ },
+ onError: () => {
+ resolve(false); // Continue even on error
+ },
+ }
+ );
+ });
+ if (success) {
+ successCount++;
+ } else {
+ errorCount++;
+ }
+ } catch {
+ errorCount++;
+ }
+ }
+
+ setIsConfirmingAll(false);
+ if (errorCount > 0) {
+ Alert.alert(
+ "Partial Success",
+ `Confirmed ${successCount} bookings. Failed to confirm ${errorCount}.`
+ );
+ } else {
+ Alert.alert("Success", `All ${successCount} bookings have been confirmed.`);
+ }
+ },
+ },
+ ]
+ );
+ },
+ [confirmBookingMutation]
+ );
+
+ // Reject all unconfirmed bookings in a recurring series
+ const handleRejectAll = React.useCallback(
+ async (group: RecurringBookingGroup) => {
+ const unconfirmedBookings = group.bookings.filter(
+ (b) =>
+ b.status?.toLowerCase() === "pending" ||
+ b.status?.toLowerCase() === "requires_confirmation" ||
+ b.requiresConfirmation
+ );
+
+ if (unconfirmedBookings.length === 0) {
+ Alert.alert("Info", "No unconfirmed bookings to reject.");
+ return;
+ }
+
+ // For Android, open dialog with reason input
+ if (Platform.OS === "android") {
+ setRejectAllGroup(group);
+ setRejectAllReason("");
+ setShowRejectAllDialog(true);
+ return;
+ }
+
+ // For iOS, use Alert.prompt
+ Alert.alert(
+ "Reject All",
+ `Are you sure you want to reject ${unconfirmedBookings.length} unconfirmed bookings?`,
+ [
+ { text: "No", style: "cancel" },
+ {
+ text: "Yes, Reject All",
+ style: "destructive",
+ onPress: () => {
+ Alert.prompt(
+ "Rejection Reason",
+ "Optionally provide a reason for rejecting (press OK to skip):",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "OK",
+ onPress: async (reason?: string) => {
+ setIsDecliningAll(true);
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const booking of unconfirmedBookings) {
+ try {
+ const success = await new Promise((resolve, _reject) => {
+ declineBookingMutation(
+ {
+ uid: booking.uid,
+ reason: reason || undefined,
+ },
+ {
+ onSuccess: () => {
+ resolve(true);
+ },
+ onError: () => {
+ resolve(false);
+ },
+ }
+ );
+ });
+
+ if (success) {
+ successCount++;
+ } else {
+ errorCount++;
+ }
+ } catch {
+ errorCount++;
+ }
+ }
+
+ setIsDecliningAll(false);
+ if (errorCount > 0) {
+ Alert.alert(
+ "Partial Success",
+ `Rejected ${successCount} bookings. Failed to reject ${errorCount}.`
+ );
+ } else {
+ Alert.alert("Success", `All ${successCount} bookings have been rejected.`);
+ }
+ },
+ },
+ ],
+ "plain-text",
+ "",
+ "default"
+ );
+ },
+ },
+ ]
+ );
+ },
+ [declineBookingMutation]
+ );
const renderBookingItem = ({ item }: { item: Booking }) => {
return (
@@ -273,17 +569,8 @@ export const BookingListScreen: React.FC = ({
isConfirming={isConfirming}
isDeclining={isDeclining}
onPress={handleBookingPress}
- onLongPress={(booking) => {
- setSelectedBooking(booking);
- setShowBookingActionsModal(true);
- }}
onConfirm={handleInlineConfirm}
onReject={handleOpenRejectModal}
- onActionsPress={(booking) => {
- setSelectedBooking(booking);
- setShowBookingActionsModal(true);
- }}
- // iOS context menu action handlers - now use screen navigation
onReschedule={handleNavigateToReschedule}
onEditLocation={handleNavigateToEditLocation}
onAddGuests={handleNavigateToAddGuests}
@@ -306,6 +593,31 @@ export const BookingListScreen: React.FC = ({
);
}
+ if (item.type === "recurringGroup") {
+ return (
+ {
+ Alert.alert("Report Booking", "Report booking functionality is not yet available");
+ }}
+ onCancelBooking={handleCancelBooking}
+ />
+ );
+ }
return renderBookingItem({ item: item.booking });
};
@@ -397,7 +709,7 @@ export const BookingListScreen: React.FC = ({
item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
@@ -414,7 +726,7 @@ export const BookingListScreen: React.FC = ({
item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
@@ -448,44 +760,16 @@ export const BookingListScreen: React.FC = ({
selectedEventTypeId={selectedEventTypeId}
onFilterClose={() => setShowFilterModal?.(false)}
onEventTypeSelect={onEventTypeChange}
- showBookingActionsModal={showBookingActionsModal}
+ showBookingActionsModal={false}
selectedBooking={selectedBooking}
- onActionsClose={() => setShowBookingActionsModal(false)}
- onReschedule={() => {
- // Navigate to reschedule screen instead of opening modal
- if (selectedBooking) {
- setShowBookingActionsModal(false);
- handleNavigateToReschedule(selectedBooking);
- }
- }}
- onCancel={() => {
- if (selectedBooking) handleCancelBooking(selectedBooking);
- }}
- onEditLocation={(booking) => {
- // Navigate to edit location screen instead of opening modal
- setShowBookingActionsModal(false);
- handleNavigateToEditLocation(booking);
- }}
- onAddGuests={(booking) => {
- // Navigate to add guests screen instead of opening modal
- setShowBookingActionsModal(false);
- handleNavigateToAddGuests(booking);
- }}
- onViewRecordings={(booking) => {
- // Navigate to view recordings screen instead of opening modal
- setShowBookingActionsModal(false);
- handleNavigateToViewRecordings(booking);
- }}
- onMeetingSessionDetails={(booking) => {
- // Navigate to meeting session details screen instead of opening modal
- setShowBookingActionsModal(false);
- handleNavigateToMeetingSessionDetails(booking);
- }}
- onMarkNoShow={(booking) => {
- // Navigate to mark no show screen instead of opening modal
- setShowBookingActionsModal(false);
- handleNavigateToMarkNoShow(booking);
- }}
+ onActionsClose={() => {}}
+ onReschedule={() => {}}
+ onCancel={() => {}}
+ onEditLocation={() => {}}
+ onAddGuests={() => {}}
+ onViewRecordings={() => {}}
+ onMeetingSessionDetails={() => {}}
+ onMarkNoShow={() => {}}
/>
{/* Cancel Event AlertDialog for Android */}
@@ -531,6 +815,174 @@ export const BookingListScreen: React.FC = ({
)}
+
+ {/* Android: Cancel All Remaining Dialog */}
+ {Platform.OS === "android" && showCancelAllDialog && cancelAllGroup && (
+
+
+
+ Cancel All Remaining
+
+ Are you sure you want to cancel all {cancelAllGroup.remainingCount} remaining
+ bookings in this series?
+
+
+
+
+
+ Cancellation Reason (required)
+
+
+
+
+
+ {
+ setShowCancelAllDialog(false);
+ setCancelAllGroup(null);
+ setCancelAllReason("");
+ }}
+ >
+ Nevermind
+
+ {
+ const reason = cancelAllReason.trim() || "Cancelled all remaining";
+ setShowCancelAllDialog(false);
+ setIsCancellingAll(true);
+
+ cancelBookingMutation(
+ { uid: cancelAllGroup.recurringBookingUid, reason },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "All remaining bookings have been cancelled.");
+ setIsCancellingAll(false);
+ setCancelAllGroup(null);
+ setCancelAllReason("");
+ },
+ onError: () => {
+ Alert.alert("Error", "Failed to cancel bookings. Please try again.");
+ setIsCancellingAll(false);
+ },
+ }
+ );
+ }}
+ >
+ Cancel All
+
+
+
+
+ )}
+
+ {/* Android: Reject All Dialog */}
+ {Platform.OS === "android" && showRejectAllDialog && rejectAllGroup && (
+
+
+
+ Reject All
+
+ Are you sure you want to reject all unconfirmed bookings in this series?
+
+
+
+
+
+ Rejection Reason (optional)
+
+
+
+
+
+ {
+ setShowRejectAllDialog(false);
+ setRejectAllGroup(null);
+ setRejectAllReason("");
+ }}
+ >
+ Nevermind
+
+ {
+ const reason = rejectAllReason.trim() || undefined;
+ setShowRejectAllDialog(false);
+ setIsDecliningAll(true);
+
+ const unconfirmedBookings = rejectAllGroup.bookings.filter(
+ (b) =>
+ b.status?.toLowerCase() === "pending" ||
+ b.status?.toLowerCase() === "requires_confirmation" ||
+ b.requiresConfirmation
+ );
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const booking of unconfirmedBookings) {
+ try {
+ const success = await new Promise((resolve, _reject) => {
+ declineBookingMutation(
+ { uid: booking.uid, reason },
+ {
+ onSuccess: () => {
+ resolve(true);
+ },
+ onError: () => {
+ resolve(false);
+ },
+ }
+ );
+ });
+
+ if (success) {
+ successCount++;
+ } else {
+ errorCount++;
+ }
+ } catch {
+ errorCount++;
+ }
+ }
+
+ setIsDecliningAll(false);
+ setRejectAllGroup(null);
+ setRejectAllReason("");
+
+ if (errorCount > 0) {
+ Alert.alert(
+ "Partial Success",
+ `Rejected ${successCount} bookings. Failed to reject ${errorCount}.`
+ );
+ } else {
+ Alert.alert("Success", `All ${successCount} bookings have been rejected.`);
+ }
+ }}
+ >
+ Reject All
+
+
+
+
+ )}
>
);
};
diff --git a/companion/components/event-type-list-item/EventTypeListItem.android.tsx b/companion/components/event-type-list-item/EventTypeListItem.android.tsx
deleted file mode 100644
index 516760c297..0000000000
--- a/companion/components/event-type-list-item/EventTypeListItem.android.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import { Pressable, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Text } from "@/components/ui/text";
-import {
- DurationBadge,
- EventTypeDescription,
- EventTypeTitle,
- PriceAndConfirmationBadges,
-} from "./EventTypeListItemParts";
-import type { EventTypeListItemProps } from "./types";
-import { useEventTypeListItemData } from "./useEventTypeListItemData";
-
-export const EventTypeListItem = ({
- item,
- index,
- filteredEventTypes,
- copiedEventTypeId: _copiedEventTypeId,
- handleEventTypePress,
- handleEventTypeLongPress: _handleEventTypeLongPress,
- handleCopyLink,
- handlePreview,
- onEdit,
- onDuplicate,
- onDelete,
-}: EventTypeListItemProps) => {
- const { formattedDuration, normalizedDescription, hasPrice, formattedPrice } =
- useEventTypeListItemData(item);
- const isLast = index === filteredEventTypes.length - 1;
- const insets = useSafeAreaInsets();
-
- const contentInsets = {
- top: insets.top,
- bottom: insets.bottom,
- left: 12,
- right: 12,
- };
-
- return (
-
-
- handleEventTypePress(item)}
- className="mr-4 flex-1"
- android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
- >
-
-
-
-
-
-
- {/* Dropdown Menu - Single Button */}
-
-
-
-
-
-
-
-
- {/* Preview & Copy Actions */}
- handlePreview(item)}>
-
- Preview
-
-
- handleCopyLink(item)}>
-
- Copy link
-
-
-
-
- {/* Edit & Duplicate Actions */}
- onEdit?.(item)}>
-
- Edit
-
-
- onDuplicate?.(item)}>
-
- Duplicate
-
-
-
-
- {/* Delete Action - Destructive */}
- onDelete?.(item)}>
-
- Delete
-
-
-
-
-
- );
-};
diff --git a/companion/components/event-type-list-item/EventTypeListItem.ios.tsx b/companion/components/event-type-list-item/EventTypeListItem.ios.tsx
index 86378d5d21..5268f84a12 100644
--- a/companion/components/event-type-list-item/EventTypeListItem.ios.tsx
+++ b/companion/components/event-type-list-item/EventTypeListItem.ios.tsx
@@ -3,12 +3,7 @@ import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import type React from "react";
import { Pressable, View } from "react-native";
-import {
- DurationBadge,
- EventTypeDescription,
- EventTypeTitle,
- PriceAndConfirmationBadges,
-} from "./EventTypeListItemParts";
+import { EventTypeBadges, EventTypeDescription, EventTypeTitle } from "./EventTypeListItemParts";
import type { EventTypeListItemProps } from "./types";
import { useEventTypeListItemData } from "./useEventTypeListItemData";
@@ -16,9 +11,7 @@ export const EventTypeListItem = ({
item,
index,
filteredEventTypes,
- copiedEventTypeId: _copiedEventTypeId,
handleEventTypePress,
- handleEventTypeLongPress: _handleEventTypeLongPress,
handleCopyLink,
handlePreview,
onEdit,
@@ -92,25 +85,35 @@ export const EventTypeListItem = ({
))}
-
+
handleEventTypePress(item)}
- style={{ paddingHorizontal: 16, paddingVertical: 16 }}
- className="flex-grow"
+ style={{
+ paddingTop: 16,
+ paddingBottom: 22,
+ paddingLeft: 16,
+ flex: 1,
+ marginRight: 12,
+ }}
>
-
-
-
-
-
-
+
+
+
-
+
{
const { formattedDuration, normalizedDescription, hasPrice, formattedPrice } =
useEventTypeListItemData(item);
const isLast = index === filteredEventTypes.length - 1;
+ const insets = useSafeAreaInsets();
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
return (
- handleEventTypePress(item)}
- onLongPress={() => handleEventTypeLongPress(item)}
- style={{ paddingHorizontal: 16, paddingVertical: 16 }}
- >
-
-
-
+
+
+ handleEventTypePress(item)}
+ style={{ flex: 1, marginRight: 12 }}
+ android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
+ >
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ handlePreview(item)}>
+
+ Preview
+
+
+ handleCopyLink(item)}>
+
+ Copy link
+
+
+
+
+ onEdit?.(item)}>
+
+ Edit
+
+
+ onDuplicate?.(item)}>
+
+ Duplicate
+
+
+
+
+ onDelete?.(item)}>
+
+ Delete
+
+
+
-
+
);
};
diff --git a/companion/components/event-type-list-item/EventTypeListItemParts.tsx b/companion/components/event-type-list-item/EventTypeListItemParts.tsx
index 83e76bf917..1f0b82be2e 100644
--- a/companion/components/event-type-list-item/EventTypeListItemParts.tsx
+++ b/companion/components/event-type-list-item/EventTypeListItemParts.tsx
@@ -6,12 +6,16 @@ import type { EventType } from "@/services/types/event-types.types";
interface EventTypeTitleProps {
title: string;
+ username?: string;
+ slug: string;
}
-export function EventTypeTitle({ title }: EventTypeTitleProps) {
+export function EventTypeTitle({ title, username, slug }: EventTypeTitleProps) {
+ const linkText = username ? `/${username}/${slug}` : `/${slug}`;
return (
-
- {title}
+
+ {title}
+ {linkText}
);
}
@@ -29,39 +33,133 @@ export function EventTypeDescription({ normalizedDescription }: EventTypeDescrip
);
}
-interface DurationBadgeProps {
+interface EventTypeLinkProps {
+ username?: string;
+ slug: string;
+}
+
+export function EventTypeLink({ username, slug }: EventTypeLinkProps) {
+ const linkText = username ? `/${username}/${slug}` : `/${slug}`;
+ return {linkText};
+}
+
+interface EventTypeBadgesProps {
formattedDuration: string;
-}
-
-export function DurationBadge({ formattedDuration }: DurationBadgeProps) {
- return (
-
-
- {formattedDuration}
-
- );
-}
-
-interface PriceAndConfirmationBadgesProps {
+ hidden?: boolean;
+ seats?: {
+ disabled?: boolean;
+ seatsPerTimeSlot?: number;
+ showAttendeeInfo?: boolean;
+ showAvailabilityCount?: boolean;
+ };
hasPrice: boolean;
formattedPrice: string | null;
- requiresConfirmation?: boolean;
+ confirmationPolicy?: EventType["confirmationPolicy"];
+ recurrence?: {
+ disabled?: boolean;
+ interval?: number;
+ occurrences?: number;
+ frequency?: string;
+ } | null;
}
-export function PriceAndConfirmationBadges({
+export function EventTypeBadges({
+ formattedDuration,
+ hidden,
+ seats,
hasPrice,
formattedPrice,
- requiresConfirmation,
-}: PriceAndConfirmationBadgesProps) {
- if (!hasPrice && !requiresConfirmation) return null;
+ confirmationPolicy,
+ recurrence,
+}: EventTypeBadgesProps) {
+ const hasSeats = seats && !seats.disabled && seats.seatsPerTimeSlot && seats.seatsPerTimeSlot > 0;
+
+ const requiresConfirmation =
+ confirmationPolicy &&
+ !("disabled" in confirmationPolicy && confirmationPolicy.disabled) &&
+ "type" in confirmationPolicy &&
+ confirmationPolicy.type === "always";
+
+ const hasRecurrence = recurrence && !recurrence.disabled && recurrence.occurrences;
+
+ // Render nothing if we seemingly have no badges (Duration is usually always present though)
+ // But checking just in case
+ if (
+ !formattedDuration &&
+ !hidden &&
+ !hasSeats &&
+ (!hasPrice || !formattedPrice) &&
+ !hasRecurrence &&
+ !requiresConfirmation
+ ) {
+ return null;
+ }
+
return (
-
+
+ {/* Duration Badge */}
+
+
+
+ {formattedDuration}
+
+
+
+ {/* Hidden Badge */}
+ {hidden ? (
+
+
+ Hidden
+
+ ) : null}
+
+ {/* Seats Badge */}
+ {hasSeats ? (
+
+
+
+ {seats.seatsPerTimeSlot} seats
+
+
+ ) : null}
+
+ {/* Price Badge */}
{hasPrice && formattedPrice ? (
{formattedPrice}
) : null}
+
+ {/* Repeats Badge */}
+ {hasRecurrence ? (
+
+
+
+ {recurrence.occurrences} times
+
+
+ ) : null}
+
+ {/* Requires Confirmation Badge */}
{requiresConfirmation ? (
-
- Requires Confirmation
+
+
+
+ Requires confirmation
+
) : null}
diff --git a/companion/components/event-type-list-item/types.ts b/companion/components/event-type-list-item/types.ts
index a344e9b2b1..4adae3c3c9 100644
--- a/companion/components/event-type-list-item/types.ts
+++ b/companion/components/event-type-list-item/types.ts
@@ -4,9 +4,7 @@ export interface EventTypeListItemProps {
item: EventType;
index: number;
filteredEventTypes: EventType[];
- copiedEventTypeId: number | null;
handleEventTypePress: (item: EventType) => void;
- handleEventTypeLongPress: (item: EventType) => void;
handleCopyLink: (item: EventType) => void;
handlePreview: (item: EventType) => void;
onEdit?: (item: EventType) => void;
diff --git a/companion/components/screens/AddGuestsScreen.tsx b/companion/components/screens/AddGuestsScreen.tsx
index 9123d17b21..7b773a099a 100644
--- a/companion/components/screens/AddGuestsScreen.tsx
+++ b/companion/components/screens/AddGuestsScreen.tsx
@@ -18,8 +18,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useAddGuests } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
-import { CalComAPIService } from "@/services/calcom";
import { safeLogError } from "@/utils/safeLogger";
export interface AddGuestsScreenProps {
@@ -51,7 +51,9 @@ export const AddGuestsScreen = forwardRef([]);
- const [isSaving, setIsSaving] = useState(false);
+
+ // Use React Query mutation for automatic cache invalidation
+ const { mutate: addGuestsMutation, isPending: isSaving } = useAddGuests();
// Notify parent of saving state changes
useEffect(() => {
@@ -92,7 +94,7 @@ export const AddGuestsScreen = forwardRef {
+ const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
if (guests.length === 0) {
@@ -100,17 +102,24 @@ export const AddGuestsScreen = forwardRef {
+ Alert.alert("Success", "Guests added successfully", [
+ { text: "OK", onPress: onSuccess },
+ ]);
+ },
+ onError: (error) => {
+ safeLogError("[AddGuestsScreen] Failed to add guests:", error);
+ Alert.alert("Error", "Failed to add guests. Please try again.");
+ },
+ }
+ );
+ }, [booking, guests, onSuccess, isSaving, addGuestsMutation]);
// Expose submit function to parent via ref (same pattern as senior's actionHandlersRef)
useImperativeHandle(
diff --git a/companion/components/screens/AvailabilityListScreen.tsx b/companion/components/screens/AvailabilityListScreen.tsx
index d81a86fb78..c975cf7273 100644
--- a/companion/components/screens/AvailabilityListScreen.tsx
+++ b/companion/components/screens/AvailabilityListScreen.tsx
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { Activity, useMemo, useState } from "react";
import {
- ActionSheetIOS,
Alert,
FlatList,
Platform,
@@ -106,75 +105,6 @@ export function AvailabilityListScreen({
onSearchChange(query);
};
- const handleScheduleLongPress = (schedule: Schedule) => {
- if (Platform.OS !== "ios") {
- // Fallback for non-iOS platforms (Android Alert supports max 3 buttons)
- const options: {
- text: string;
- onPress: () => void;
- style?: "destructive" | "cancel" | "default";
- }[] = [];
- if (!schedule.isDefault) {
- options.push({
- text: "Set as default",
- onPress: () => handleSetAsDefault(schedule),
- });
- }
- options.push(
- { text: "Duplicate", onPress: () => handleDuplicate(schedule) },
- {
- text: "Delete",
- style: "destructive" as const,
- onPress: () => handleDelete(schedule),
- }
- );
- // Android Alert automatically adds cancel, so we don't need to include it explicitly
- Alert.alert(schedule.name, "", options);
- return;
- }
-
- const options = ["Cancel"];
- if (!schedule.isDefault) {
- options.push("Set as default");
- }
- options.push("Duplicate", "Delete");
-
- const destructiveButtonIndex = options.length - 1; // Delete button
- const cancelButtonIndex = 0;
-
- ActionSheetIOS.showActionSheetWithOptions(
- {
- options,
- destructiveButtonIndex,
- cancelButtonIndex,
- title: schedule.name,
- },
- (buttonIndex) => {
- if (buttonIndex === cancelButtonIndex) {
- return;
- }
-
- if (!schedule.isDefault) {
- // Options: ["Cancel", "Set as default", "Duplicate", "Delete"]
- if (buttonIndex === 1) {
- handleSetAsDefault(schedule);
- } else if (buttonIndex === 2) {
- handleDuplicate(schedule);
- } else if (buttonIndex === 3) {
- handleDelete(schedule);
- }
- } else {
- // Options: ["Cancel", "Duplicate", "Delete"]
- if (buttonIndex === 1) {
- handleDuplicate(schedule);
- } else if (buttonIndex === 2) {
- handleDelete(schedule);
- }
- }
- }
- );
- };
-
const handleSetAsDefault = (schedule: Schedule) => {
setAsDefaultMutation(schedule.id, {
onError: () => {
@@ -433,9 +363,6 @@ export function AvailabilityListScreen({
item={item}
index={index}
handleSchedulePress={handleSchedulePress}
- handleScheduleLongPress={handleScheduleLongPress}
- setSelectedSchedule={setSelectedSchedule}
- setShowActionsModal={setShowActionsModal}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
onSetAsDefault={handleSetAsDefault}
diff --git a/companion/components/screens/BookingDetailScreen.android.tsx b/companion/components/screens/BookingDetailScreen.android.tsx
deleted file mode 100644
index b99976fbca..0000000000
--- a/companion/components/screens/BookingDetailScreen.android.tsx
+++ /dev/null
@@ -1,846 +0,0 @@
-import { Ionicons } from "@expo/vector-icons";
-import { Stack, useRouter } from "expo-router";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import {
- ActivityIndicator,
- Alert,
- Pressable,
- ScrollView,
- Text,
- TextInput,
- View,
-} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { AppPressable } from "@/components/AppPressable";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Text as UIText } from "@/components/ui/text";
-import { SvgImage } from "@/components/SvgImage";
-import { useAuth } from "@/contexts/AuthContext";
-import { type Booking, CalComAPIService } from "@/services/calcom";
-import { showErrorAlert } 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 = {
- reschedule: { visible: false, enabled: false },
- rescheduleRequest: { visible: false, enabled: false },
- cancel: { visible: false, enabled: false },
- changeLocation: { visible: false, enabled: false },
- addGuests: { visible: false, enabled: false },
- viewRecordings: { visible: false, enabled: false },
- meetingSessionDetails: { visible: false, enabled: false },
- markNoShow: { visible: false, enabled: false },
-};
-
-// Format date: "Tuesday, November 25, 2025"
-const formatDateFull = (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",
- 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}`;
-};
-
-// 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,
- };
- }
- // 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,
- };
-};
-
-export interface BookingDetailScreenProps {
- uid: string;
- onActionsReady?: (handlers: {
- openRescheduleModal: () => void;
- openEditLocationModal: () => void;
- openAddGuestsModal: () => void;
- openViewRecordingsModal: () => void;
- openMeetingSessionDetailsModal: () => void;
- openMarkNoShowModal: () => void;
- handleCancelBooking: () => void;
- }) => void;
-}
-
-export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps) {
- const router = useRouter();
- const { userInfo } = useAuth();
- const insets = useSafeAreaInsets();
-
- const [loading, setLoading] = useState(true);
- const [booking, setBooking] = useState(null);
- const [error, setError] = useState(null);
- const [isCancelling, setIsCancelling] = useState(false);
- const [showCancelDialog, setShowCancelDialog] = useState(false);
- const [cancellationReason, setCancellationReason] = useState("");
-
- const contentInsets = {
- top: insets.top,
- bottom: insets.bottom,
- left: 12,
- right: 12,
- };
-
- // Compute actions using centralized gating
- const actions = useMemo(() => {
- if (!booking) return EMPTY_ACTIONS;
- return getBookingActions({
- booking,
- eventType: undefined,
- currentUserId: userInfo?.id,
- currentUserEmail: userInfo?.email,
- isOnline: true,
- });
- }, [booking, userInfo?.id, userInfo?.email]);
-
- // Cancel booking handler
- const performCancelBooking = useCallback(
- async (reason: string) => {
- if (!booking) return;
-
- setIsCancelling(true);
-
- try {
- await CalComAPIService.cancelBooking(booking.uid, reason);
- Alert.alert("Success", "Booking cancelled successfully", [
- {
- text: "OK",
- onPress: () => router.back(),
- },
- ]);
- setIsCancelling(false);
- } catch (err) {
- console.error("Failed to cancel booking");
- if (__DEV__) {
- const message = err instanceof Error ? err.message : String(err);
- console.debug("[BookingDetailScreen.android] cancelBooking failed", {
- message,
- });
- }
- showErrorAlert("Error", "Failed to cancel booking. Please try again.");
- setIsCancelling(false);
- }
- },
- [booking, router]
- );
-
- const handleCancelBooking = useCallback(() => {
- if (!booking) return;
- setCancellationReason("");
- setShowCancelDialog(true);
- }, [booking]);
-
- const handleConfirmCancel = useCallback(() => {
- const reason = cancellationReason.trim() || "Cancelled by host";
- setShowCancelDialog(false);
- setCancellationReason("");
- performCancelBooking(reason);
- }, [cancellationReason, performCancelBooking]);
-
- const handleCloseCancelDialog = useCallback(() => {
- setShowCancelDialog(false);
- setCancellationReason("");
- }, []);
-
- // Navigate to reschedule screen
- const openRescheduleModal = useCallback(() => {
- if (!booking) return;
- router.push({
- pathname: "/reschedule",
- params: { uid: booking.uid },
- });
- }, [booking, router]);
-
- // Navigate to edit location screen
- const openEditLocationModal = useCallback(() => {
- if (!booking) return;
- router.push({
- pathname: "/edit-location",
- params: { uid: booking.uid },
- });
- }, [booking, router]);
-
- // Navigate to add guests screen
- const openAddGuestsModal = useCallback(() => {
- if (!booking) return;
- router.push({
- pathname: "/add-guests",
- params: { uid: booking.uid },
- });
- }, [booking, router]);
-
- // Navigate to mark no show screen
- const openMarkNoShowModal = useCallback(() => {
- if (!booking) return;
- router.push({
- pathname: "/mark-no-show",
- params: { uid: booking.uid },
- });
- }, [booking, router]);
-
- // Navigate to view recordings screen
- const openViewRecordingsModal = useCallback(() => {
- if (!booking) return;
- router.push({
- pathname: "/view-recordings",
- params: { uid: booking.uid },
- });
- }, [booking, router]);
-
- // Navigate to meeting session details screen
- const openMeetingSessionDetailsModal = useCallback(() => {
- if (!booking) return;
- router.push({
- pathname: "/meeting-session-details",
- params: { uid: booking.uid },
- });
- }, [booking, router]);
-
- const handleReportBooking = useCallback(() => {
- Alert.alert("Report Booking", "Report booking functionality is not yet available");
- }, []);
-
- const fetchBooking = useCallback(async () => {
- setLoading(true);
- setError(null);
- let bookingData: Booking | null = null;
- let fetchError: Error | null = null;
-
- try {
- bookingData = await CalComAPIService.getBookingByUid(uid);
- } catch (err) {
- fetchError = err instanceof Error ? err : new Error(String(err));
- }
-
- if (bookingData) {
- if (__DEV__) {
- const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
- const attendeeCount = bookingData.attendees?.length ?? 0;
- console.debug("[BookingDetailScreen.android] booking fetched", {
- uid: bookingData.uid,
- status: bookingData.status,
- hostCount,
- attendeeCount,
- hasRecurringEventId: Boolean(bookingData.recurringEventId),
- });
- }
- setBooking(bookingData);
- setLoading(false);
- } else {
- console.error("Error fetching booking");
- if (__DEV__ && fetchError) {
- console.debug("[BookingDetailScreen.android] fetchBooking failed", {
- message: fetchError.message,
- stack: fetchError.stack,
- });
- }
- setError("Failed to load booking. Please try again.");
- if (__DEV__) {
- Alert.alert("Error", "Failed to load booking. Please try again.", [
- { text: "OK", onPress: () => router.back() },
- ]);
- } else {
- router.back();
- }
- setLoading(false);
- }
- }, [uid, router]);
-
- useEffect(() => {
- if (uid) {
- fetchBooking();
- } else {
- setLoading(false);
- setError("Invalid booking ID");
- }
- }, [uid, fetchBooking]);
-
- // Expose action handlers to parent component
- useEffect(() => {
- if (booking && onActionsReady) {
- onActionsReady({
- openRescheduleModal,
- openEditLocationModal,
- openAddGuestsModal,
- openViewRecordingsModal,
- openMeetingSessionDetailsModal,
- openMarkNoShowModal,
- handleCancelBooking,
- });
- }
- }, [
- booking,
- onActionsReady,
- openRescheduleModal,
- openEditLocationModal,
- openAddGuestsModal,
- openViewRecordingsModal,
- openMeetingSessionDetailsModal,
- handleCancelBooking,
- openMarkNoShowModal,
- ]);
-
- const handleJoinMeeting = () => {
- if (!booking?.location) return;
-
- const provider = getLocationProvider(booking.location);
- if (provider?.url) {
- openInAppBrowser(provider.url, "meeting link");
- }
- };
-
- // Build dropdown menu actions
- const dropdownActions = useMemo(() => {
- if (!booking) return [];
-
- const _startTime = booking.start || booking.startTime || "";
- const endTime = booking.end || booking.endTime || "";
- const isPast = new Date(endTime) < new Date();
- const isCancelled = booking.status.toLowerCase() === "cancelled";
- const isPending = booking.status.toLowerCase() === "pending";
- const isUpcoming = !isPast;
-
- type DropdownAction = {
- label: string;
- icon: keyof typeof Ionicons.glyphMap;
- onPress: () => void;
- variant?: "default" | "destructive";
- visible: boolean;
- };
-
- const allActions: DropdownAction[] = [
- // Edit Event Section
- {
- label: "Reschedule Booking",
- icon: "calendar-outline",
- onPress: openRescheduleModal,
- variant: "default",
- visible: isUpcoming && !isCancelled && !isPending,
- },
- {
- label: "Edit Location",
- icon: "location-outline",
- onPress: openEditLocationModal,
- variant: "default",
- visible: isUpcoming && !isCancelled && !isPending,
- },
- {
- label: "Add Guests",
- icon: "person-add-outline",
- onPress: openAddGuestsModal,
- variant: "default",
- visible: isUpcoming && !isCancelled && !isPending,
- },
- // After Event Section
- {
- label: "View Recordings",
- icon: "videocam-outline",
- onPress: openViewRecordingsModal,
- variant: "default",
- visible: actions.viewRecordings.visible && actions.viewRecordings.enabled,
- },
- {
- label: "Meeting Session Details",
- icon: "information-circle-outline",
- onPress: openMeetingSessionDetailsModal,
- variant: "default",
- visible: actions.meetingSessionDetails.visible && actions.meetingSessionDetails.enabled,
- },
- {
- label: "Mark as No-Show",
- icon: "eye-off-outline",
- onPress: openMarkNoShowModal,
- variant: "default",
- visible: actions.markNoShow.visible && actions.markNoShow.enabled,
- },
- // Other Actions
- {
- label: "Report Booking",
- icon: "flag-outline",
- onPress: handleReportBooking,
- variant: "destructive",
- visible: true,
- },
- {
- label: "Cancel Event",
- icon: "close-circle-outline",
- onPress: handleCancelBooking,
- variant: "destructive",
- visible: isUpcoming && !isCancelled,
- },
- ];
-
- return allActions.filter((action) => action.visible);
- }, [
- booking,
- actions,
- openRescheduleModal,
- openEditLocationModal,
- openAddGuestsModal,
- openViewRecordingsModal,
- openMeetingSessionDetailsModal,
- openMarkNoShowModal,
- handleReportBooking,
- handleCancelBooking,
- ]);
-
- // Find the index where destructive actions start
- const destructiveStartIndex = dropdownActions.findIndex(
- (action) => action.variant === "destructive"
- );
-
- if (loading) {
- return (
-
-
- Loading booking...
-
- );
- }
-
- if (error || !booking) {
- return (
-
-
-
- {error || "Booking not found"}
-
- router.back()}>
- Go Back
-
-
- );
- }
-
- 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);
-
- return (
- <>
- {/* Header with DropdownMenu */}
- (
-
-
-
-
-
-
-
-
- {dropdownActions.map((action, index) => (
-
- {/* Add separator before destructive actions */}
- {index === destructiveStartIndex && destructiveStartIndex > 0 && (
-
- )}
-
-
-
- {action.label}
-
-
-
- ))}
-
-
- ),
- }}
- />
-
-
- {/* Title */}
-
- {booking.title}
-
- {dateFormatted} {timeFormatted} ({timezone})
-
-
-
- {/* 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}
-
-
- ) : 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;
- return (
- 0 ? "mt-4" : ""}`}
- >
-
-
- {getInitials(attendee.name)}
-
-
-
-
-
- {attendee.name}
-
- {isNoShow && (
-
-
-
- No-show
-
-
- )}
-
-
- {attendee.email}
-
-
-
- );
- })}
-
- ) : 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 */}
- {booking.description ? (
-
- Description
- {booking.description}
-
- ) : null}
-
- {/* Join Meeting Button */}
- {locationProvider?.url ? (
-
- {locationProvider.iconUrl ? (
-
- ) : null}
-
- Join {locationProvider.label}
-
-
- ) : null}
-
-
- {/* Cancelling overlay */}
- {isCancelling && (
-
-
-
-
- Cancelling booking...
-
-
-
- )}
-
-
- {/* Cancel Event AlertDialog */}
-
-
-
-
- Cancel event
-
-
-
- Cancellation reason will be shared with guests
-
-
-
-
- {/* Reason Input */}
-
- Reason for cancellation
-
-
-
-
-
- Nevermind
-
-
- Cancel event
-
-
-
-
- >
- );
-}
diff --git a/companion/components/screens/BookingDetailScreen.ios.tsx b/companion/components/screens/BookingDetailScreen.ios.tsx
index 6b6732e9d0..bff3d66043 100644
--- a/companion/components/screens/BookingDetailScreen.ios.tsx
+++ b/companion/components/screens/BookingDetailScreen.ios.tsx
@@ -1,10 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
-import { ActivityIndicator, Alert, ScrollView, Text, View } from "react-native";
+import { ActivityIndicator, Alert, RefreshControl, ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
-import { type Booking, CalComAPIService } from "@/services/calcom";
+import { useCancelBooking } from "@/hooks/useBookings";
+import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
// Format date for iOS Calendar style: "Thursday, 25 Dec 2025"
@@ -64,7 +65,30 @@ const calculateDuration = (startDateString: string, endDateString: string): numb
};
export interface BookingDetailScreenProps {
- uid: string;
+ /**
+ * The booking data to display. When null/undefined, shows loading or error state.
+ */
+ booking: Booking | null | undefined;
+ /**
+ * Whether the booking data is currently being fetched.
+ */
+ isLoading: boolean;
+ /**
+ * Error that occurred while fetching the booking, if any.
+ */
+ error: Error | null;
+ /**
+ * Function to refetch the booking data. Used for pull-to-refresh.
+ */
+ refetch: () => void;
+ /**
+ * Whether a refetch is currently in progress. Used for RefreshControl.
+ */
+ isRefetching?: boolean;
+ /**
+ * Callback to expose internal action handlers to parent component.
+ * Used by iOS header menu to trigger actions like reschedule.
+ */
onActionsReady?: (handlers: {
openRescheduleModal: () => void;
openEditLocationModal: () => void;
@@ -77,46 +101,51 @@ export interface BookingDetailScreenProps {
}
export function BookingDetailScreen({
- uid,
+ booking,
+ isLoading,
+ error,
+ refetch,
+ isRefetching = false,
onActionsReady,
}: BookingDetailScreenProps): React.JSX.Element {
const router = useRouter();
const insets = useSafeAreaInsets();
- const [loading, setLoading] = useState(true);
- const [booking, setBooking] = useState(null);
- const [error, setError] = useState(null);
- const [isCancelling, setIsCancelling] = useState(false);
const [participantsExpanded, setParticipantsExpanded] = useState(true);
+ // Cancel booking mutation
+ const cancelBookingMutation = useCancelBooking();
+ const isCancelling = cancelBookingMutation.isPending;
+
const performCancelBooking = useCallback(
- async (reason: string) => {
+ (reason: string) => {
if (!booking) return;
- setIsCancelling(true);
-
- try {
- await CalComAPIService.cancelBooking(booking.uid, reason);
- Alert.alert("Success", "Booking cancelled successfully", [
- {
- text: "OK",
- onPress: () => router.back(),
+ cancelBookingMutation.mutate(
+ { uid: booking.uid, reason },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "Booking cancelled successfully", [
+ {
+ text: "OK",
+ onPress: () => router.back(),
+ },
+ ]);
+ },
+ onError: (err) => {
+ console.error("Failed to cancel booking");
+ if (__DEV__) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.debug("[BookingDetailScreen.ios] cancelBooking failed", {
+ message,
+ });
+ }
+ showErrorAlert("Error", "Failed to cancel booking. Please try again.");
},
- ]);
- setIsCancelling(false);
- } catch (err) {
- console.error("Failed to cancel booking");
- if (__DEV__) {
- const message = err instanceof Error ? err.message : String(err);
- console.debug("[BookingDetailScreen.ios] cancelBooking failed", {
- message,
- });
}
- showErrorAlert("Error", "Failed to cancel booking. Please try again.");
- setIsCancelling(false);
- }
+ );
},
- [booking, router]
+ [booking, router, cancelBookingMutation]
);
const handleCancelBooking = useCallback(() => {
@@ -198,61 +227,6 @@ export function BookingDetailScreen({
});
}, [booking, router]);
- const fetchBooking = useCallback(async () => {
- setLoading(true);
- setError(null);
- let bookingData: Booking | null = null;
- let fetchError: Error | null = null;
-
- try {
- bookingData = await CalComAPIService.getBookingByUid(uid);
- } catch (err) {
- fetchError = err instanceof Error ? err : new Error(String(err));
- }
-
- if (bookingData) {
- if (__DEV__) {
- const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
- const attendeeCount = bookingData.attendees?.length ?? 0;
- console.debug("[BookingDetailScreen.ios] booking fetched", {
- uid: bookingData.uid,
- status: bookingData.status,
- hostCount,
- attendeeCount,
- hasRecurringEventId: Boolean(bookingData.recurringEventId),
- });
- }
- setBooking(bookingData);
- setLoading(false);
- } else {
- console.error("Error fetching booking");
- if (__DEV__ && fetchError) {
- console.debug("[BookingDetailScreen.ios] fetchBooking failed", {
- message: fetchError.message,
- stack: fetchError.stack,
- });
- }
- setError("Failed to load booking. Please try again.");
- if (__DEV__) {
- Alert.alert("Error", "Failed to load booking. Please try again.", [
- { text: "OK", onPress: () => router.back() },
- ]);
- } else {
- router.back();
- }
- setLoading(false);
- }
- }, [uid, router]);
-
- useEffect(() => {
- if (uid) {
- fetchBooking();
- } else {
- setLoading(false);
- setError("Invalid booking ID");
- }
- }, [uid, fetchBooking]);
-
useEffect(() => {
if (booking && onActionsReady) {
onActionsReady({
@@ -277,7 +251,7 @@ export function BookingDetailScreen({
openMarkNoShowModal,
]);
- if (loading) {
+ if (isLoading) {
return (
@@ -287,11 +261,12 @@ export function BookingDetailScreen({
}
if (error || !booking) {
+ const errorMessage = error?.message || "Booking not found";
return (
- {error || "Booking not found"}
+ {errorMessage}
router.back()}>
Go Back
@@ -361,6 +336,7 @@ export function BookingDetailScreen({
paddingBottom: insets.bottom + 100,
}}
showsVerticalScrollIndicator={false}
+ refreshControl={}
>
{/* Title Section - iOS Calendar Style */}
diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx
index 4c77990a86..ae7cc02752 100644
--- a/companion/components/screens/BookingDetailScreen.tsx
+++ b/companion/components/screens/BookingDetailScreen.tsx
@@ -1,12 +1,43 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { ActivityIndicator, Alert, Platform, ScrollView, Text, View } from "react-native";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Platform,
+ Pressable,
+ RefreshControl,
+ ScrollView,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ 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 { type Booking, CalComAPIService } from "@/services/calcom";
+import { useCancelBooking } from "@/hooks/useBookings";
+import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { openInAppBrowser } from "@/utils/browser";
@@ -159,7 +190,26 @@ const getLocationProvider = (location: string | undefined, metadata?: Record void;
+ /**
+ * Whether a refetch is currently in progress. Used for RefreshControl.
+ */
+ isRefetching?: boolean;
/**
* Callback to expose internal action handlers to parent component.
* Used by iOS header menu to trigger actions like reschedule.
@@ -175,15 +225,32 @@ export interface BookingDetailScreenProps {
}) => void;
}
-export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps) {
+export function BookingDetailScreen({
+ booking,
+ isLoading,
+ error,
+ refetch,
+ isRefetching = false,
+ onActionsReady,
+}: BookingDetailScreenProps) {
const router = useRouter();
const { userInfo } = useAuth();
+ const insets = useSafeAreaInsets();
- const [loading, setLoading] = useState(true);
- const [booking, setBooking] = useState(null);
- const [error, setError] = useState(null);
const [showActionsModal, setShowActionsModal] = useState(false);
- const [isCancelling, setIsCancelling] = useState(false);
+ const [showCancelDialog, setShowCancelDialog] = useState(false);
+ const [cancellationReason, setCancellationReason] = useState("");
+
+ // Cancel booking mutation
+ const cancelBookingMutation = useCancelBooking();
+ const isCancelling = cancelBookingMutation.isPending;
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
// Compute actions using centralized gating
const actions = useMemo(() => {
@@ -199,71 +266,90 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
// Cancel booking handler (needs to be defined before useEffect that exposes it)
const performCancelBooking = useCallback(
- async (reason: string) => {
+ (reason: string) => {
if (!booking) return;
- setIsCancelling(true);
- setShowActionsModal(false);
-
- try {
- await CalComAPIService.cancelBooking(booking.uid, reason);
- Alert.alert("Success", "Booking cancelled successfully", [
- {
- text: "OK",
- onPress: () => router.back(),
+ cancelBookingMutation.mutate(
+ { uid: booking.uid, reason },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "Booking cancelled successfully", [
+ {
+ text: "OK",
+ onPress: () => router.back(),
+ },
+ ]);
+ },
+ onError: (err) => {
+ console.error("Failed to cancel booking");
+ if (__DEV__) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.debug("[BookingDetailScreen] cancelBooking failed", { message });
+ }
+ showErrorAlert("Error", "Failed to cancel booking. Please try again.");
},
- ]);
- setIsCancelling(false);
- } catch (error) {
- console.error("Failed to cancel booking");
- if (__DEV__) {
- const message = error instanceof Error ? error.message : String(error);
- console.debug("[BookingDetailScreen] cancelBooking failed", { message });
}
- showErrorAlert("Error", "Failed to cancel booking. Please try again.");
- setIsCancelling(false);
- }
+ );
},
- [booking, router]
+ [booking, router, cancelBookingMutation]
);
const handleCancelBooking = useCallback(() => {
if (!booking) return;
- Alert.alert("Cancel Booking", `Are you sure you want to cancel "${booking.title}"?`, [
- { text: "No", style: "cancel" },
- {
- text: "Yes, Cancel",
- style: "destructive",
- onPress: () => {
- // Prompt for cancellation reason (iOS only supports Alert.prompt)
- if (Platform.OS === "ios") {
- Alert.prompt(
- "Cancellation Reason",
- "Please provide a reason for cancelling this booking:",
- [
- { text: "Cancel", style: "cancel" },
- {
- text: "Cancel Booking",
- style: "destructive",
- onPress: (reason?: string) => {
- performCancelBooking(reason?.trim() || "Cancelled by host");
+ if (Platform.OS === "android") {
+ setCancellationReason("");
+ setShowCancelDialog(true);
+ } else {
+ Alert.alert("Cancel Booking", `Are you sure you want to cancel "${booking.title}"?`, [
+ { text: "No", style: "cancel" },
+ {
+ text: "Yes, Cancel",
+ style: "destructive",
+ onPress: () => {
+ if (Platform.OS === "ios") {
+ Alert.prompt(
+ "Cancellation Reason",
+ "Please provide a reason for cancelling this booking:",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Cancel Booking",
+ style: "destructive",
+ onPress: (reason?: string) => {
+ performCancelBooking(reason?.trim() || "Cancelled by host");
+ },
},
- },
- ],
- "plain-text",
- "",
- "default"
- );
- } else {
- // For Android, just cancel with default reason
- performCancelBooking("Cancelled by host");
- }
+ ],
+ "plain-text",
+ "",
+ "default"
+ );
+ } else {
+ performCancelBooking("Cancelled by host");
+ }
+ },
},
- },
- ]);
+ ]);
+ }
}, [booking, performCancelBooking]);
+ const handleConfirmCancel = useCallback(() => {
+ const reason = cancellationReason.trim() || "Cancelled by host";
+ setShowCancelDialog(false);
+ setCancellationReason("");
+ performCancelBooking(reason);
+ }, [cancellationReason, performCancelBooking]);
+
+ const handleCloseCancelDialog = useCallback(() => {
+ setShowCancelDialog(false);
+ setCancellationReason("");
+ }, []);
+
+ const handleReportBooking = useCallback(() => {
+ Alert.alert("Report Booking", "Report booking functionality is not yet available");
+ }, []);
+
// Navigate to reschedule screen (same pattern as senior's - navigate to screen in same folder)
const openRescheduleModal = useCallback(() => {
if (!booking) return;
@@ -318,61 +404,6 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
});
}, [booking, router]);
- const fetchBooking = useCallback(async () => {
- setLoading(true);
- setError(null);
- let bookingData: Booking | null = null;
- let fetchError: Error | null = null;
-
- try {
- bookingData = await CalComAPIService.getBookingByUid(uid);
- } catch (err) {
- fetchError = err instanceof Error ? err : new Error(String(err));
- }
-
- if (bookingData) {
- if (__DEV__) {
- const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
- const attendeeCount = bookingData.attendees?.length ?? 0;
- console.debug("[BookingDetailScreen] booking fetched", {
- uid: bookingData.uid,
- status: bookingData.status,
- hostCount,
- attendeeCount,
- hasRecurringEventId: Boolean(bookingData.recurringEventId),
- });
- }
- setBooking(bookingData);
- setLoading(false);
- } else {
- console.error("Error fetching booking");
- if (__DEV__ && fetchError) {
- console.debug("[BookingDetailScreen] fetchBooking failed", {
- message: fetchError.message,
- stack: fetchError.stack,
- });
- }
- setError("Failed to load booking. Please try again.");
- if (__DEV__) {
- Alert.alert("Error", "Failed to load booking. Please try again.", [
- { text: "OK", onPress: () => router.back() },
- ]);
- } else {
- router.back();
- }
- setLoading(false);
- }
- }, [uid, router]);
-
- useEffect(() => {
- if (uid) {
- fetchBooking();
- } else {
- setLoading(false);
- setError("Invalid booking ID");
- }
- }, [uid, fetchBooking]);
-
// Expose action handlers to parent component (for iOS header menu)
useEffect(() => {
if (booking && onActionsReady) {
@@ -407,7 +438,89 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
}
};
- if (loading) {
+ const dropdownActions = useMemo(() => {
+ if (!booking) return [];
+
+ type DropdownAction = {
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ visible: boolean;
+ variant?: "default" | "destructive";
+ };
+
+ const allActions: DropdownAction[] = [
+ {
+ label: "Reschedule Booking",
+ icon: "calendar-outline",
+ onPress: openRescheduleModal,
+ visible: actions.reschedule.visible && actions.reschedule.enabled,
+ },
+ {
+ label: "Edit Location",
+ icon: "location-outline",
+ onPress: openEditLocationModal,
+ visible: actions.changeLocation.visible && actions.changeLocation.enabled,
+ },
+ {
+ label: "Add Guests",
+ icon: "people-outline",
+ onPress: openAddGuestsModal,
+ visible: actions.addGuests.visible && actions.addGuests.enabled,
+ },
+ {
+ label: "View Recordings",
+ icon: "videocam-outline",
+ onPress: openViewRecordingsModal,
+ visible: actions.viewRecordings.visible && actions.viewRecordings.enabled,
+ },
+ {
+ label: "Meeting Session Details",
+ icon: "information-circle-outline",
+ onPress: openMeetingSessionDetailsModal,
+ visible: actions.meetingSessionDetails.visible && actions.meetingSessionDetails.enabled,
+ },
+ {
+ label: "Mark as No-Show",
+ icon: "eye-off-outline",
+ onPress: openMarkNoShowModal,
+ visible: actions.markNoShow.visible && actions.markNoShow.enabled,
+ },
+ {
+ label: "Report Booking",
+ icon: "flag-outline",
+ onPress: handleReportBooking,
+ visible: true,
+ variant: "destructive",
+ },
+ {
+ label: "Cancel Event",
+ icon: "close-circle-outline",
+ onPress: handleCancelBooking,
+ visible: actions.cancel.visible && actions.cancel.enabled,
+ variant: "destructive",
+ },
+ ];
+
+ return allActions.filter((action) => action.visible);
+ }, [
+ booking,
+ actions,
+ openRescheduleModal,
+ openEditLocationModal,
+ openAddGuestsModal,
+ openViewRecordingsModal,
+ openMeetingSessionDetailsModal,
+ openMarkNoShowModal,
+ handleReportBooking,
+ handleCancelBooking,
+ ]);
+
+ const destructiveStartIndex = dropdownActions.findIndex(
+ (action) => action.variant === "destructive"
+ );
+
+ if (isLoading) {
return (
@@ -417,11 +530,12 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
}
if (error || !booking) {
+ const errorMessage = error?.message || "Booking not found";
return (
- {error || "Booking not found"}
+ {errorMessage}
router.back()}>
Go Back
@@ -444,18 +558,53 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
(
- setShowActionsModal(true)}
- >
-
-
+
+
+
+
+
+
+
+
+
+ {dropdownActions.map((action, index) => (
+
+ {index === destructiveStartIndex && destructiveStartIndex > 0 && (
+
+ )}
+
+
+
+ {action.label}
+
+
+
+ ))}
+
+
+
),
}}
/>
)}
-
+ }
+ >
{/* Title */}
{booking.title}
@@ -529,7 +678,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
>
@@ -540,21 +689,21 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
{attendee.name}
{isNoShow && (
-
-
+
+
No-show
)}
-
+
{attendee.email}
@@ -672,6 +821,47 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
)}
+
+ {/* Cancel Event AlertDialog (Android only) */}
+
+
+
+
+ Cancel event
+
+
+
+ Cancellation reason will be shared with guests
+
+
+
+
+
+ Reason for cancellation
+
+
+
+
+
+ Nevermind
+
+
+ Cancel event
+
+
+
+
>
);
}
diff --git a/companion/components/screens/EditLocationScreen.ios.tsx b/companion/components/screens/EditLocationScreen.ios.tsx
index fe88b21223..1fa794b088 100644
--- a/companion/components/screens/EditLocationScreen.ios.tsx
+++ b/companion/components/screens/EditLocationScreen.ios.tsx
@@ -12,8 +12,8 @@ import { isLiquidGlassAvailable } from "expo-glass-effect";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useUpdateLocation } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
-import { CalComAPIService } from "@/services/calcom";
import { safeLogError } from "@/utils/safeLogger";
// Location types configuration
@@ -96,7 +96,9 @@ export const EditLocationScreen = forwardRef("link");
const [inputValue, setInputValue] = useState("");
- const [isSaving, setIsSaving] = useState(false);
+
+ // Use React Query mutation for automatic cache invalidation
+ const { mutate: updateLocation, isPending: isSaving } = useUpdateLocation();
// Detect location type from current location but don't pre-fill the input
useEffect(() => {
@@ -124,7 +126,7 @@ export const EditLocationScreen = forwardRef {
+ const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
const trimmedValue = inputValue.trim();
@@ -159,19 +161,24 @@ export const EditLocationScreen = forwardRef {
+ Alert.alert("Success", "Location updated successfully", [
+ { text: "OK", onPress: onSuccess },
+ ]);
+ },
+ onError: (error) => {
+ safeLogError("[EditLocationScreen] Failed to update location:", error);
+ Alert.alert("Error", "Failed to update location. Please try again.");
+ },
+ }
+ );
+ }, [booking, selectedType, inputValue, onSuccess, isSaving, updateLocation]);
// Expose submit function to parent via ref
useImperativeHandle(
diff --git a/companion/components/screens/EditLocationScreen.tsx b/companion/components/screens/EditLocationScreen.tsx
index c9ee742d2a..04e87cde45 100644
--- a/companion/components/screens/EditLocationScreen.tsx
+++ b/companion/components/screens/EditLocationScreen.tsx
@@ -29,8 +29,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useUpdateLocation } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
-import { CalComAPIService } from "@/services/calcom";
import { safeLogError } from "@/utils/safeLogger";
export const LOCATION_TYPES = {
@@ -113,7 +113,9 @@ export const EditLocationScreen = forwardRef("link");
const [inputValue, setInputValue] = useState("");
const [showTypePicker, setShowTypePicker] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
+
+ // Use React Query mutation for automatic cache invalidation
+ const { mutate: updateLocation, isPending: isSaving } = useUpdateLocation();
// Detect location type from current location but don't pre-fill the input
useEffect(() => {
@@ -130,7 +132,7 @@ export const EditLocationScreen = forwardRef {
+ const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
const trimmedValue = inputValue.trim();
@@ -164,19 +166,24 @@ export const EditLocationScreen = forwardRef {
+ Alert.alert("Success", "Location updated successfully", [
+ { text: "OK", onPress: onSuccess },
+ ]);
+ },
+ onError: (error) => {
+ safeLogError("[EditLocationScreen] Failed to update location:", error);
+ Alert.alert("Error", "Failed to update location. Please try again.");
+ },
+ }
+ );
+ }, [booking, selectedType, inputValue, onSuccess, isSaving, updateLocation]);
useImperativeHandle(
ref,
diff --git a/companion/components/screens/MarkNoShowScreen.tsx b/companion/components/screens/MarkNoShowScreen.tsx
index 6897b411aa..f2d8b3d100 100644
--- a/companion/components/screens/MarkNoShowScreen.tsx
+++ b/companion/components/screens/MarkNoShowScreen.tsx
@@ -9,8 +9,8 @@ import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { ActivityIndicator, Alert, FlatList, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useMarkNoShow } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
-import { CalComAPIService } from "@/services/calcom";
interface Attendee {
id?: number | string;
@@ -53,14 +53,16 @@ export function MarkNoShowScreen({
onBookingUpdate,
transparentBackground = false,
}: MarkNoShowScreenProps) {
- "use no memo";
const insets = useSafeAreaInsets();
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]";
const safeAttendees = Array.isArray(attendees) ? attendees : [];
const [processingEmail, setProcessingEmail] = useState(null);
- const handleMarkNoShow = async (attendee: Attendee) => {
+ // Mark no-show mutation
+ const markNoShowMutation = useMarkNoShow();
+
+ const handleMarkNoShow = (attendee: Attendee) => {
if (!booking) return;
const isCurrentlyNoShow = attendee.noShow === true;
@@ -74,65 +76,72 @@ export function MarkNoShowScreen({
{
text: "Confirm",
style: isCurrentlyNoShow ? "default" : "destructive",
- onPress: async () => {
+ onPress: () => {
setProcessingEmail(attendee.email);
- try {
- const updatedBooking = await CalComAPIService.markAbsent(
- booking.uid,
- attendee.email,
- !isCurrentlyNoShow
- );
+ markNoShowMutation.mutate(
+ {
+ uid: booking.uid,
+ attendeeEmail: attendee.email,
+ absent: !isCurrentlyNoShow,
+ },
+ {
+ onSuccess: (updatedBooking) => {
+ // API returns "absent" field, not "noShow"
+ const updatedAttendees: Attendee[] = [];
+ if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
+ updatedBooking.attendees.forEach(
+ (att: {
+ id?: number | string;
+ email: string;
+ name?: string;
+ noShow?: boolean;
+ absent?: boolean;
+ }) => {
+ updatedAttendees.push({
+ id: att.id,
+ email: att.email,
+ name: att.name || att.email,
+ noShow: att.absent === true || att.noShow === true,
+ });
+ }
+ );
+ }
- // API returns "absent" field, not "noShow"
- const updatedAttendees: Attendee[] = [];
- if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
- updatedBooking.attendees.forEach(
- (att: {
- id?: number | string;
- email: string;
- name?: string;
- noShow?: boolean;
- absent?: boolean;
- }) => {
- updatedAttendees.push({
- id: att.id,
- email: att.email,
- name: att.name || att.email,
- noShow: att.absent === true || att.noShow === true,
+ onUpdate(updatedAttendees);
+
+ if (onBookingUpdate) {
+ onBookingUpdate(updatedBooking);
+ }
+
+ Alert.alert(
+ "Success",
+ `${attendee.name || attendee.email} has been ${
+ isCurrentlyNoShow ? "unmarked as no-show" : "marked as no-show"
+ }`
+ );
+ setProcessingEmail(null);
+ },
+ onError: (error) => {
+ console.error("[MarkNoShowScreen] Failed to mark no-show:", error);
+ if (__DEV__) {
+ const message = error instanceof Error ? error.message : String(error);
+ const stack = error instanceof Error ? error.stack : undefined;
+ console.debug("[MarkNoShowScreen] Error details:", {
+ message,
+ stack,
+ attendeeEmail: maskEmail(attendee.email),
+ bookingUid: booking.uid,
+ absent: !isCurrentlyNoShow,
});
}
- );
+ Alert.alert(
+ "Error",
+ error instanceof Error ? error.message : `Failed to ${action}`
+ );
+ setProcessingEmail(null);
+ },
}
-
- onUpdate(updatedAttendees);
-
- if (onBookingUpdate) {
- onBookingUpdate(updatedBooking);
- }
-
- Alert.alert(
- "Success",
- `${attendee.name || attendee.email} has been ${
- isCurrentlyNoShow ? "unmarked as no-show" : "marked as no-show"
- }`
- );
- setProcessingEmail(null);
- } catch (error) {
- console.error("[MarkNoShowScreen] Failed to mark no-show:", error);
- if (__DEV__) {
- const message = error instanceof Error ? error.message : String(error);
- const stack = error instanceof Error ? error.stack : undefined;
- console.debug("[MarkNoShowScreen] Error details:", {
- message,
- stack,
- attendeeEmail: maskEmail(attendee.email),
- bookingUid: booking.uid,
- absent: !isCurrentlyNoShow,
- });
- }
- Alert.alert("Error", error instanceof Error ? error.message : `Failed to ${action}`);
- setProcessingEmail(null);
- }
+ );
},
},
]
@@ -171,7 +180,7 @@ export function MarkNoShowScreen({
>
{getInitials(item.name)}
@@ -182,8 +191,8 @@ export function MarkNoShowScreen({
{item.email}
{isNoShow && (
-
- Marked as no-show
+
+ Marked as no-show
)}
@@ -201,11 +210,11 @@ export function MarkNoShowScreen({
{isNoShow ? "Unmark" : "Mark"}
diff --git a/companion/components/screens/RescheduleScreen.android.tsx b/companion/components/screens/RescheduleScreen.android.tsx
index 27b8a4ceb0..9ab0b05bd6 100644
--- a/companion/components/screens/RescheduleScreen.android.tsx
+++ b/companion/components/screens/RescheduleScreen.android.tsx
@@ -20,8 +20,8 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
+import { useRescheduleBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
-import { CalComAPIService } from "@/services/calcom";
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
export interface RescheduleScreenProps {
@@ -52,7 +52,9 @@ export const RescheduleScreen = forwardRef {
if (booking?.startTime) {
@@ -68,7 +70,7 @@ export const RescheduleScreen = forwardRef {
+ const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
if (selectedDateTime <= new Date()) {
@@ -76,23 +78,26 @@ export const RescheduleScreen = forwardRef {
+ Alert.alert("Success", "Booking rescheduled successfully", [
+ { text: "OK", onPress: onSuccess },
+ ]);
+ },
+ onError: (error) => {
+ safeLogError("[RescheduleScreen] Failed to reschedule:", error);
+ Alert.alert("Error", "Failed to reschedule booking. Please try again.");
+ },
+ }
+ );
+ }, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
// Format date for display
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
diff --git a/companion/components/screens/RescheduleScreen.ios.tsx b/companion/components/screens/RescheduleScreen.ios.tsx
index 66e2853a79..364fd0a833 100644
--- a/companion/components/screens/RescheduleScreen.ios.tsx
+++ b/companion/components/screens/RescheduleScreen.ios.tsx
@@ -10,8 +10,8 @@ import { Ionicons } from "@expo/vector-icons";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useRescheduleBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
-import { CalComAPIService } from "@/services/calcom";
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
export interface RescheduleScreenProps {
@@ -31,12 +31,13 @@ export const RescheduleScreen = forwardRef(new Date());
const [reason, setReason] = useState("");
- const [isSaving, setIsSaving] = useState(false);
+
+ // Use React Query mutation for automatic cache invalidation
+ const { mutate: rescheduleBooking, isPending: isSaving } = useRescheduleBooking();
useEffect(() => {
if (booking?.startTime) {
@@ -52,7 +53,7 @@ export const RescheduleScreen = forwardRef {
+ const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
if (selectedDateTime <= new Date()) {
@@ -60,22 +61,25 @@ export const RescheduleScreen = forwardRef {
+ Alert.alert("Success", "Booking rescheduled successfully", [
+ { text: "OK", onPress: onSuccess },
+ ]);
+ },
+ onError: (error) => {
+ safeLogError("[RescheduleScreen] Failed to reschedule:", error);
+ Alert.alert("Error", "Failed to reschedule booking. Please try again.");
+ },
+ }
+ );
+ }, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
const handleDateSelected = useCallback(
(date: Date) => {
diff --git a/companion/components/screens/RescheduleScreen.tsx b/companion/components/screens/RescheduleScreen.tsx
index 4e9bf5af4c..426508c0a7 100644
--- a/companion/components/screens/RescheduleScreen.tsx
+++ b/companion/components/screens/RescheduleScreen.tsx
@@ -20,8 +20,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useRescheduleBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
-import { CalComAPIService } from "@/services/calcom";
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
const isWeb = Platform.OS === "web";
@@ -51,7 +51,9 @@ export const RescheduleScreen = forwardRef {
@@ -69,7 +71,7 @@ export const RescheduleScreen = forwardRef {
+ const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
// Validate the date is in the future
@@ -78,27 +80,30 @@ export const RescheduleScreen = forwardRef 0 ? trimmedReason : undefined;
const startTime = selectedDateTime.toISOString();
- setIsSaving(true);
- try {
- await CalComAPIService.rescheduleBooking(booking.uid, {
+ rescheduleBooking(
+ {
+ uid: booking.uid,
start: startTime,
reschedulingReason,
- });
- Alert.alert("Success", "Booking rescheduled successfully", [
- { text: "OK", onPress: onSuccess },
- ]);
- setIsSaving(false);
- } catch (error) {
- safeLogError("[RescheduleScreen] Failed to reschedule:", error);
- Alert.alert("Error", "Failed to reschedule booking. Please try again.");
- setIsSaving(false);
- }
- }, [booking, selectedDateTime, reason, onSuccess, isSaving]);
+ },
+ {
+ onSuccess: () => {
+ Alert.alert("Success", "Booking rescheduled successfully", [
+ { text: "OK", onPress: onSuccess },
+ ]);
+ },
+ onError: (error) => {
+ safeLogError("[RescheduleScreen] Failed to reschedule:", error);
+ Alert.alert("Error", "Failed to reschedule booking. Please try again.");
+ },
+ }
+ );
+ }, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
// Helper function to format date as YYYY-MM-DD in local timezone (avoids UTC conversion issues)
const formatLocalDate = (date: Date) => {
diff --git a/companion/global.css b/companion/global.css
index eab6c1b08f..d05574c26b 100644
--- a/companion/global.css
+++ b/companion/global.css
@@ -18,7 +18,7 @@
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
- --destructive: 0 84.2% 60.2%;
+ --destructive: 350 100% 25%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
@@ -41,7 +41,7 @@
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
- --destructive: 0 62.8% 30.6%;
+ --destructive: 350 100% 15%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
diff --git a/companion/hooks/useActiveBookingFilter.tsx b/companion/hooks/useActiveBookingFilter.tsx
index d2600276d4..612cff0d85 100644
--- a/companion/hooks/useActiveBookingFilter.tsx
+++ b/companion/hooks/useActiveBookingFilter.tsx
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import type { NativeSyntheticEvent } from "react-native";
import type { BookingFilters } from "./useBookings";
-export type BookingFilter = "upcoming" | "unconfirmed" | "past" | "cancelled";
+export type BookingFilter = "upcoming" | "unconfirmed" | "recurring" | "past" | "cancelled";
export interface BookingFilterOption {
key: BookingFilter;
@@ -12,6 +12,7 @@ export interface BookingFilterOption {
const FILTER_OPTIONS: BookingFilterOption[] = [
{ key: "upcoming", label: "Upcoming" },
{ key: "unconfirmed", label: "Unconfirmed" },
+ { key: "recurring", label: "Recurring" },
{ key: "past", label: "Past" },
{ key: "cancelled", label: "Cancelled" },
];
@@ -71,6 +72,8 @@ export function useActiveBookingFilter(
return { status: ["upcoming"], limit: 50 };
case "unconfirmed":
return { status: ["unconfirmed"], limit: 50 };
+ case "recurring":
+ return { status: ["recurring"], limit: 100 };
case "past":
return { status: ["past"], limit: 100 };
case "cancelled":
diff --git a/companion/hooks/useBookings.ts b/companion/hooks/useBookings.ts
index 1e2569aa6d..d8dda5df98 100644
--- a/companion/hooks/useBookings.ts
+++ b/companion/hooks/useBookings.ts
@@ -116,6 +116,46 @@ export function useCancelBooking() {
});
}
+/**
+ * Hook to mark an attendee as no-show (absent)
+ *
+ * @returns Mutation function and state
+ *
+ * @example
+ * ```tsx
+ * const { mutate: markNoShow, isPending } = useMarkNoShow();
+ *
+ * markNoShow({ uid: 'abc-123', attendeeEmail: 'attendee@example.com', absent: true });
+ * ```
+ */
+export function useMarkNoShow() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ uid,
+ attendeeEmail,
+ absent,
+ }: {
+ uid: string;
+ attendeeEmail: string;
+ absent: boolean;
+ }) => CalComAPIService.markAbsent(uid, attendeeEmail, absent),
+ onSuccess: (_, variables) => {
+ // Invalidate all booking queries to refetch fresh data
+ queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
+
+ // Also invalidate the specific booking detail
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.bookings.detail(variables.uid),
+ });
+ },
+ onError: (_error) => {
+ console.error("Failed to mark attendee as no-show");
+ },
+ });
+}
+
/**
* Hook to reschedule a booking
*
@@ -225,6 +265,83 @@ export function useDeclineBooking() {
});
}
+/**
+ * Hook to update the location of a booking
+ *
+ * @returns Mutation function and state
+ *
+ * @example
+ * ```tsx
+ * const { mutate: updateLocation, isPending } = useUpdateLocation();
+ *
+ * updateLocation({
+ * uid: 'abc-123',
+ * location: { type: 'link', link: 'https://meet.example.com' }
+ * });
+ * ```
+ */
+export function useUpdateLocation() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ uid,
+ location,
+ }: {
+ uid: string;
+ location: { type: string; [key: string]: string };
+ }) => CalComAPIService.updateLocationV2(uid, location),
+ onSuccess: (_, variables) => {
+ // Invalidate all booking queries
+ queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
+
+ // Also invalidate the specific booking detail
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.bookings.detail(variables.uid),
+ });
+ },
+ onError: (_error) => {
+ console.error("Failed to update location");
+ },
+ });
+}
+
+/**
+ * Hook to add guests to a booking
+ *
+ * @returns Mutation function and state
+ *
+ * @example
+ * ```tsx
+ * const { mutate: addGuests, isPending } = useAddGuests();
+ *
+ * addGuests({
+ * uid: 'abc-123',
+ * guests: [{ email: 'guest@example.com', name: 'Guest Name' }]
+ * });
+ * ```
+ */
+export function useAddGuests() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ uid, guests }: { uid: string; guests: { email: string; name?: string }[] }) =>
+ CalComAPIService.addGuests(uid, guests),
+ onSuccess: (_, variables) => {
+ // Invalidate all booking queries
+ queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
+
+ // Also invalidate the specific booking detail
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.bookings.detail(variables.uid),
+ });
+ },
+ onError: (_error) => {
+ console.error("Failed to add guests");
+ },
+ });
+}
+
/**
* Hook to prefetch bookings (useful for navigation)
*
diff --git a/companion/hooks/useEventTypeFilter.tsx b/companion/hooks/useEventTypeFilter.tsx
new file mode 100644
index 0000000000..cffa71112e
--- /dev/null
+++ b/companion/hooks/useEventTypeFilter.tsx
@@ -0,0 +1,191 @@
+import { useCallback, useMemo, useState } from "react";
+import type { EventType } from "@/services/calcom";
+
+// Sort options for event types
+export type EventTypeSortOption = "alphabetical" | "newest" | "duration";
+
+// Filter options (multi-select toggles)
+// When ALL filters are OFF: show ALL event types (no filtering)
+// When a filter is ON: only show events matching that filter
+export interface EventTypeFilters {
+ hiddenOnly: boolean; // When ON, show ONLY hidden events
+ paidOnly: boolean; // When ON, show ONLY paid events
+ seatedOnly: boolean; // When ON, show ONLY seated events
+ requiresConfirmationOnly: boolean; // When ON, show ONLY events requiring confirmation
+ recurringOnly: boolean; // When ON, show ONLY recurring events
+}
+
+const DEFAULT_FILTERS: EventTypeFilters = {
+ hiddenOnly: false,
+ paidOnly: false,
+ seatedOnly: false,
+ requiresConfirmationOnly: false,
+ recurringOnly: false,
+};
+
+interface UseEventTypeFilterResult {
+ // State
+ sortBy: EventTypeSortOption;
+ filters: EventTypeFilters;
+
+ // Actions
+ setSortBy: (sort: EventTypeSortOption) => void;
+ toggleFilter: (filterKey: keyof EventTypeFilters) => void;
+ resetFilters: () => void;
+
+ // Computed
+ filteredAndSortedEventTypes: (eventTypes: EventType[]) => EventType[];
+ activeFilterCount: number;
+}
+
+/**
+ * Helper function to sort event types by the given sort option.
+ */
+function sortEventTypes(eventTypes: EventType[], sortBy: EventTypeSortOption): EventType[] {
+ return [...eventTypes].sort((a, b) => {
+ switch (sortBy) {
+ case "alphabetical":
+ return a.title.localeCompare(b.title);
+ case "newest":
+ return b.id - a.id;
+ case "duration": {
+ const durationA = a.lengthInMinutes || a.length || 0;
+ const durationB = b.lengthInMinutes || b.length || 0;
+ return durationA - durationB;
+ }
+ default:
+ return 0;
+ }
+ });
+}
+
+/**
+ * Hook to manage event type filtering and sorting.
+ *
+ * Supports:
+ * - Sorting: Alphabetical, Newest First, By Duration
+ * - Filters (multi-select toggles):
+ * - Include Hidden: Show hidden event types
+ * - Paid Only: Show only paid events (price > 0)
+ * - Seated Only: Show only seated events
+ * - Requires Confirmation: Show only events requiring confirmation
+ * - Recurring Only: Show only recurring events
+ */
+export function useEventTypeFilter(): UseEventTypeFilterResult {
+ const [sortBy, setSortBy] = useState("alphabetical");
+ const [filters, setFilters] = useState(DEFAULT_FILTERS);
+
+ const toggleFilter = useCallback((filterKey: keyof EventTypeFilters) => {
+ setFilters((prev) => ({
+ ...prev,
+ [filterKey]: !prev[filterKey],
+ }));
+ }, []);
+
+ const resetFilters = useCallback(() => {
+ setFilters(DEFAULT_FILTERS);
+ }, []);
+
+ // Count active filters
+ const activeFilterCount = useMemo(() => {
+ let count = 0;
+ if (filters.hiddenOnly) count++;
+ if (filters.paidOnly) count++;
+ if (filters.seatedOnly) count++;
+ if (filters.requiresConfirmationOnly) count++;
+ if (filters.recurringOnly) count++;
+ return count;
+ }, [filters]);
+
+ const filteredAndSortedEventTypes = useCallback(
+ (eventTypes: EventType[]): EventType[] => {
+ if (!eventTypes || eventTypes.length === 0) {
+ return [];
+ }
+
+ // Check if ANY filter is ON
+ const hasAnyFilter =
+ filters.hiddenOnly ||
+ filters.paidOnly ||
+ filters.seatedOnly ||
+ filters.requiresConfirmationOnly ||
+ filters.recurringOnly;
+
+ // If NO filters are enabled, show ALL event types (no filtering)
+ if (!hasAnyFilter) {
+ return sortEventTypes(eventTypes, sortBy);
+ }
+
+ // Apply filters - each filter narrows down results (AND logic)
+ const filtered = eventTypes.filter((eventType) => {
+ // Hidden filter: when ON, show ONLY hidden events
+ if (filters.hiddenOnly && eventType.hidden !== true) {
+ return false;
+ }
+
+ // Paid filter: if enabled, only show paid events (price > 0)
+ if (filters.paidOnly) {
+ const isPaid =
+ eventType.price !== undefined && eventType.price !== null && eventType.price > 0;
+ if (!isPaid) {
+ return false;
+ }
+ }
+
+ // Seated filter: check if seats is defined and not disabled
+ if (filters.seatedOnly) {
+ const hasSeats =
+ eventType.seats &&
+ typeof eventType.seats === "object" &&
+ !("disabled" in eventType.seats && eventType.seats.disabled === true);
+ if (!hasSeats) {
+ return false;
+ }
+ }
+
+ // Requires confirmation filter - check both requiresConfirmation and confirmationPolicy
+ if (filters.requiresConfirmationOnly) {
+ const hasRequiresConfirmation = eventType.requiresConfirmation === true;
+ const hasConfirmationPolicy =
+ eventType.confirmationPolicy &&
+ typeof eventType.confirmationPolicy === "object" &&
+ !(
+ "disabled" in eventType.confirmationPolicy &&
+ eventType.confirmationPolicy.disabled === true
+ );
+
+ if (!hasRequiresConfirmation && !hasConfirmationPolicy) {
+ return false;
+ }
+ }
+
+ // Recurring filter: check if recurrence is defined and not disabled
+ if (filters.recurringOnly) {
+ const isRecurring =
+ eventType.recurrence &&
+ typeof eventType.recurrence === "object" &&
+ !("disabled" in eventType.recurrence && eventType.recurrence.disabled === true);
+ if (!isRecurring) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+
+ // Apply sorting using helper function
+ return sortEventTypes(filtered, sortBy);
+ },
+ [sortBy, filters]
+ );
+
+ return {
+ sortBy,
+ filters,
+ setSortBy,
+ toggleFilter,
+ resetFilters,
+ filteredAndSortedEventTypes,
+ activeFilterCount,
+ };
+}
diff --git a/companion/services/calcom.ts b/companion/services/calcom.ts
index 17ef2881dd..21ec29daf9 100644
--- a/companion/services/calcom.ts
+++ b/companion/services/calcom.ts
@@ -184,6 +184,8 @@ export const getBookingParticipation = (
// Module-level state (previously private static)
let _userProfile: UserProfile | null = null;
+// In-flight promise to prevent concurrent /me API calls
+let _userProfilePromise: Promise | null = null;
/**
* Set OAuth access token for authentication
@@ -288,14 +290,32 @@ async function updateUserProfile(updates: {
}
}
-// Get and cache user profile
+// Get and cache user profile with in-flight deduplication
+// This prevents multiple concurrent callers from each making a /me API call
async function getUserProfile(): Promise {
+ // Return cached profile if available
if (_userProfile) {
return _userProfile;
}
- _userProfile = await getCurrentUser();
- return _userProfile;
+ // If there's already an in-flight request, wait for it instead of making a new one
+ if (_userProfilePromise) {
+ return _userProfilePromise;
+ }
+
+ // Create a new request and cache the promise to deduplicate concurrent calls
+ _userProfilePromise = getCurrentUser()
+ .then((profile) => {
+ _userProfile = profile;
+ _userProfilePromise = null;
+ return profile;
+ })
+ .catch((error) => {
+ _userProfilePromise = null;
+ throw error;
+ });
+
+ return _userProfilePromise;
}
// Get cached username or fetch if not available
@@ -313,6 +333,7 @@ async function buildEventTypeLink(eventTypeSlug: string): Promise {
// Clear cached profile (useful for logout)
function clearUserProfile(): void {
_userProfile = null;
+ _userProfilePromise = null;
}
// Test function for bookings API specifically
@@ -853,13 +874,13 @@ async function getTranscripts(bookingUid: string): Promise
}
async function getEventTypes(): Promise {
- // Get current user to extract username
+ // Get cached user profile to extract username (uses in-flight deduplication)
let username: string | undefined;
try {
- const currentUser = await getCurrentUser();
+ const userProfile = await getUserProfile();
// Extract username from response
- if (currentUser?.username) {
- username = currentUser.username;
+ if (userProfile?.username) {
+ username = userProfile.username;
}
} catch (_error) {}
@@ -1011,10 +1032,10 @@ async function getBookings(filters?: {
}
}
- // Get current user to filter bookings
- let currentUser: UserProfile | undefined;
+ // Get cached user profile to filter bookings (uses in-flight deduplication)
+ let userProfile: UserProfile | undefined;
try {
- currentUser = await getCurrentUser();
+ userProfile = await getUserProfile();
} catch (_error) {
return bookingsArray;
}
@@ -1023,9 +1044,9 @@ async function getBookings(filters?: {
let userId: number | undefined;
let userEmail: string | undefined;
- if (currentUser) {
- userId = currentUser.id;
- userEmail = currentUser.email;
+ if (userProfile) {
+ userId = userProfile.id;
+ userEmail = userProfile.email;
}
// Filter bookings to only show ones where the current user is participating
diff --git a/companion/services/types/bookings.types.ts b/companion/services/types/bookings.types.ts
index 0eb7677f10..508e357e90 100644
--- a/companion/services/types/bookings.types.ts
+++ b/companion/services/types/bookings.types.ts
@@ -47,6 +47,7 @@ export interface Booking {
fromReschedule?: string;
recurringEventId?: string;
recurringBookingUid?: string;
+ requiresConfirmation?: boolean;
smsReminderNumber?: string;
location?: string;
cancellationReason?: string;
diff --git a/companion/services/types/event-types.types.ts b/companion/services/types/event-types.types.ts
index e92e847b93..2e68122a86 100644
--- a/companion/services/types/event-types.types.ts
+++ b/companion/services/types/event-types.types.ts
@@ -48,6 +48,7 @@ export interface BookerLayouts {
}
export interface ConfirmationPolicy {
+ type?: "always";
noticeThreshold?: {
count: number;
unit: "hours" | "minutes";
@@ -216,6 +217,16 @@ export interface EventType {
userId: number;
isFixed: boolean;
}>;
+ users?: Array<{
+ id: number;
+ name?: string;
+ username?: string;
+ avatarUrl?: string;
+ brandColor?: string | null;
+ darkBrandColor?: string | null;
+ weekStart?: string;
+ metadata?: Record;
+ }>;
// Metadata
metadata?: Record;
diff --git a/companion/tailwind.config.js b/companion/tailwind.config.js
index 2ad4deaaab..03b4542c61 100644
--- a/companion/tailwind.config.js
+++ b/companion/tailwind.config.js
@@ -60,7 +60,7 @@ module.exports = {
success: "#34C759",
warning: "#FF9500",
error: "#FF3B30",
- destructive: "#DC2626",
+ destructive: "#800020",
},
brand: {
DEFAULT: "#292929",
diff --git a/companion/utils/bookings-utils.ts b/companion/utils/bookings-utils.ts
index e2909626ff..46544466c9 100644
--- a/companion/utils/bookings-utils.ts
+++ b/companion/utils/bookings-utils.ts
@@ -29,6 +29,12 @@ export const getEmptyStateContent = (activeFilter: BookingFilter) => {
title: "No cancelled bookings",
text: "Your canceled bookings will show up here.",
};
+ case "recurring":
+ return {
+ icon: "repeat-outline" as const,
+ title: "No recurring bookings",
+ text: "Your recurring bookings will show up here.",
+ };
default:
return {
icon: "calendar-outline" as const,
@@ -154,12 +160,68 @@ export const getMonthYearKey = (dateString: string): string => {
}
};
+/**
+ * Format recurrence pattern into human-readable text
+ * @param bookings Array of bookings in the recurring series
+ * @returns Formatted recurrence text (e.g., "Every week for 5 occurrences")
+ */
+export const formatRecurrencePattern = (bookings: Booking[]): string | null => {
+ if (bookings.length === 0) return null;
+
+ // Try to determine frequency from booking dates
+ if (bookings.length < 2) {
+ return `${bookings.length} occurrence`;
+ }
+
+ // Sort bookings by date
+ const sortedBookings = [...bookings].sort((a, b) => {
+ const aTime = new Date(a.start || a.startTime || "").getTime();
+ const bTime = new Date(b.start || b.startTime || "").getTime();
+ return aTime - bTime;
+ });
+
+ // Calculate interval between first two bookings
+ const first = new Date(sortedBookings[0].start || sortedBookings[0].startTime || "");
+ const second = new Date(sortedBookings[1].start || sortedBookings[1].startTime || "");
+ const diffMs = second.getTime() - first.getTime();
+ const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
+
+ let frequency = "";
+ if (diffDays === 1) {
+ frequency = "day";
+ } else if (diffDays === 7) {
+ frequency = "week";
+ } else if (diffDays >= 28 && diffDays <= 31) {
+ frequency = "month";
+ } else if (diffDays >= 365 && diffDays <= 366) {
+ frequency = "year";
+ } else {
+ // Unknown frequency, just show count
+ return `${bookings.length} occurrences`;
+ }
+
+ return `Every ${frequency} for ${bookings.length} occurrences`;
+};
+
+/**
+ * Represents a group of recurring bookings with the same recurringBookingUid
+ */
+export interface RecurringBookingGroup {
+ recurringBookingUid: string;
+ bookings: Booking[];
+ firstUpcoming: Booking;
+ remainingCount: number;
+ hasUnconfirmed: boolean;
+ recurrenceText: string | null;
+}
+
/**
* Type definition for list items (used in FlatList)
*/
export type ListItem =
| { type: "monthHeader"; monthYear: string; key: string }
- | { type: "booking"; booking: Booking; key: string };
+ | { type: "booking"; booking: Booking; key: string }
+ | { type: "recurringGroup"; group: RecurringBookingGroup; key: string };
/**
* Group bookings by month for display in a sectioned list
@@ -196,6 +258,76 @@ export const groupBookingsByMonth = (bookings: Booking[]): ListItem[] => {
return grouped;
};
+/**
+ * Group recurring bookings by their recurringBookingUid
+ * @param bookings Array of recurring bookings to group
+ * @returns Array of RecurringBookingGroup objects
+ */
+export const groupRecurringBookings = (bookings: Booking[]): RecurringBookingGroup[] => {
+ const now = new Date();
+ const groupMap = new Map();
+
+ // Group bookings by recurringBookingUid
+ bookings.forEach((booking) => {
+ const uid = booking.recurringBookingUid;
+ if (!uid) return;
+
+ if (!groupMap.has(uid)) {
+ groupMap.set(uid, []);
+ }
+ groupMap.get(uid)?.push(booking);
+ });
+
+ // Convert map to RecurringBookingGroup array
+ const groups: RecurringBookingGroup[] = [];
+
+ groupMap.forEach((groupBookings, recurringBookingUid) => {
+ // Sort bookings by start time (ascending)
+ const sortedBookings = [...groupBookings].sort((a, b) => {
+ const aStart = new Date(a.start || a.startTime || "").getTime();
+ const bStart = new Date(b.start || b.startTime || "").getTime();
+ return aStart - bStart;
+ });
+
+ // Find first upcoming (non-cancelled) booking
+ const upcomingBookings = sortedBookings.filter((b) => {
+ const startTime = new Date(b.start || b.startTime || "");
+ const isCancelled = b.status?.toLowerCase() === "cancelled";
+ const isRejected = b.status?.toLowerCase() === "rejected";
+ return startTime >= now && !isCancelled && !isRejected;
+ });
+
+ const firstUpcoming = upcomingBookings[0] || sortedBookings[0];
+
+ // Count remaining (non-cancelled, non-rejected) bookings - reuse upcomingBookings
+ const remainingCount = upcomingBookings.length;
+
+ // Check if any booking requires confirmation
+ const hasUnconfirmed = sortedBookings.some(
+ (b) =>
+ b.status?.toLowerCase() === "pending" ||
+ b.status?.toLowerCase() === "requires_confirmation" ||
+ b.requiresConfirmation
+ );
+
+ groups.push({
+ recurringBookingUid,
+ bookings: sortedBookings,
+ firstUpcoming,
+ remainingCount,
+ hasUnconfirmed,
+ recurrenceText: formatRecurrencePattern(sortedBookings),
+ });
+ });
+
+ // Sort groups by first upcoming booking date
+ return groups.sort((a, b) => {
+ const aStart = new Date(a.firstUpcoming.start || a.firstUpcoming.startTime || "").getTime();
+ const bStart = new Date(b.firstUpcoming.start || b.firstUpcoming.startTime || "").getTime();
+ return aStart - bStart;
+ });
+};
+
/**
* Search/filter bookings by query string
* @param bookings Array of bookings to filter