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() { - + ); } 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() { {(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) => ( +