From aa127b2c2b2e68d541f8dc79ebc7254a161637c6 Mon Sep 17 00:00:00 2001 From: Beto <43630417+betomoedano@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:38:42 -0600 Subject: [PATCH] refactor: (companion) (iOS) create an ios screen for specific native logic (#26081) * create an ios screen for specific native logic * feat: update bookings layout to conditionally show header on iOS * feat: add useActiveBookingFilter hook for managing booking filter state - Introduced a new hook, useActiveBookingFilter, to manage the active booking filter state and provide related utilities. - Added filter options for "upcoming", "unconfirmed", "past", and "cancelled" bookings. - Updated index.ts to export the new hook and its types for UI state management. * refactor: streamline booking filter management and remove unused code - Integrated the useActiveBookingFilter hook to manage booking filter state, simplifying the filter logic. - Removed redundant state variables and functions related to booking filters. - Cleaned up the code by eliminating unused imports and commented-out code sections. - Updated the booking fetching logic to utilize the new filter parameters directly. * refactor: move meeting URL extraction and info retrieval to utility module - Extracted meeting URL extraction and meeting info retrieval logic into a new utility module, meetings-utils.ts. - Updated index.ios.tsx and index.tsx to utilize the new getMeetingInfo function from the utility module, simplifying the component code. - Removed redundant helper functions from the component files to enhance maintainability. * refactor: extract empty state content logic into utility module - Moved the getEmptyStateContent function from the Bookings component files to a new utility module, bookings-utils.ts, to enhance code reusability and maintainability. - Updated index.ios.tsx and index.tsx to utilize the new utility function, simplifying the component code and improving clarity. - Removed redundant empty state logic from the component files. * refactor: enhance booking filtering and utility functions - Updated the Bookings component to utilize new utility functions for filtering bookings by event type and search queries, improving code clarity and maintainability. - Moved formatting functions (formatTime, formatDate, formatMonthYear, getMonthYearKey) and grouping logic (groupBookingsByMonth) to the bookings-utils module, centralizing related functionality. - Removed redundant code from the Bookings component files, streamlining the overall structure. * refactor: implement booking actions hook for improved management - Introduced a new `useBookingActions` hook to centralize booking-related actions such as rescheduling, confirming, and rejecting bookings, enhancing code organization and reusability. - Updated the `Bookings` component to utilize the new hook, simplifying the component logic and reducing redundancy. - Removed outdated state management and functions related to booking actions, streamlining the overall code structure. * refactor: consolidate booking components and enhance structure - Refactored the Bookings component to utilize a new BookingListScreen component, improving code organization and readability. - Introduced BookingListItem for individual booking rendering, streamlining the rendering logic and enhancing maintainability. - Removed redundant state management and functions from the Bookings component, simplifying the overall structure. - Updated modal handling for booking actions, rescheduling, and rejection to improve user experience and code clarity. * feat: add BookingListItem component for enhanced booking display - Introduced a new BookingListItem component for rendering individual booking details, improving modularity and reusability. - Implemented context menu actions for booking management, including rescheduling, editing location, and adding guests. - Updated type definitions for BookingListItemProps to include additional action handlers for better functionality. - Refactored existing BookingListItem component to utilize the new props structure, enhancing clarity and maintainability. * refactor: simplify layout and styling in BookingListItem and BookingListScreen components - Updated the BookingListItem component to streamline the layout by reducing padding and adjusting the gap between elements. - Modified the BookingListScreen component to remove unnecessary horizontal padding in the content container style, enhancing the overall layout consistency. --- companion/app/(tabs)/(bookings)/_layout.tsx | 10 + companion/app/(tabs)/(bookings)/index.ios.tsx | 179 ++ companion/app/(tabs)/(bookings)/index.tsx | 153 ++ .../app/(tabs)/(event-types)/_layout.tsx | 2 +- companion/app/(tabs)/_layout.tsx | 6 +- companion/app/(tabs)/bookings.tsx | 1532 ----------------- .../booking-list-item/BookingListItem.ios.tsx | 262 +++ .../booking-list-item/BookingListItem.tsx | 158 ++ .../components/booking-list-item/types.ts | 22 + .../booking-list-screen/BookingListScreen.tsx | 406 +++++ .../booking-modals/BookingModals.tsx | 347 ++++ companion/hooks/index.ts | 9 + companion/hooks/useActiveBookingFilter.tsx | 135 ++ companion/hooks/useBookingActions.ts | 361 ++++ companion/utils/bookings-utils.ts | 359 ++++ companion/utils/meetings-utils.ts | 93 + 16 files changed, 2498 insertions(+), 1536 deletions(-) create mode 100644 companion/app/(tabs)/(bookings)/_layout.tsx create mode 100644 companion/app/(tabs)/(bookings)/index.ios.tsx create mode 100644 companion/app/(tabs)/(bookings)/index.tsx delete mode 100644 companion/app/(tabs)/bookings.tsx create mode 100644 companion/components/booking-list-item/BookingListItem.ios.tsx create mode 100644 companion/components/booking-list-item/BookingListItem.tsx create mode 100644 companion/components/booking-list-item/types.ts create mode 100644 companion/components/booking-list-screen/BookingListScreen.tsx create mode 100644 companion/components/booking-modals/BookingModals.tsx create mode 100644 companion/hooks/useActiveBookingFilter.tsx create mode 100644 companion/hooks/useBookingActions.ts create mode 100644 companion/utils/bookings-utils.ts create mode 100644 companion/utils/meetings-utils.ts diff --git a/companion/app/(tabs)/(bookings)/_layout.tsx b/companion/app/(tabs)/(bookings)/_layout.tsx new file mode 100644 index 0000000000..4521f9ea72 --- /dev/null +++ b/companion/app/(tabs)/(bookings)/_layout.tsx @@ -0,0 +1,10 @@ +import { Stack } from "expo-router"; +import { Platform } from "react-native"; + +export default function BookingsLayout() { + return ( + + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/index.ios.tsx b/companion/app/(tabs)/(bookings)/index.ios.tsx new file mode 100644 index 0000000000..b1aadf2da4 --- /dev/null +++ b/companion/app/(tabs)/(bookings)/index.ios.tsx @@ -0,0 +1,179 @@ +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack } from "expo-router"; +import React, { useState } from "react"; +import type { NativeStackHeaderItemMenuAction } from "@react-navigation/native-stack"; + +import { BookingListScreen } from "../../../components/booking-list-screen/BookingListScreen"; +import { useActiveBookingFilter } from "../../../hooks/useActiveBookingFilter"; +import { useEventTypes } from "../../../hooks"; + +export default function Bookings() { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedEventTypeId, setSelectedEventTypeId] = useState(null); + const { data: eventTypes } = useEventTypes(); + + // Use the active booking filter hook + const { activeFilter, filterOptions, filterParams, handleFilterChange } = useActiveBookingFilter( + "upcoming", + () => { + // Clear dependent filters when status filter changes + setSearchQuery(""); + setSelectedEventTypeId(null); + } + ); + + const handleSearch = (query: string) => { + setSearchQuery(query); + }; + + const clearEventTypeFilter = () => { + setSelectedEventTypeId(null); + }; + + const buildActiveBookingFilter = () => { + const currentFilterOption = filterOptions.find((option) => option.key === activeFilter); + const statusMenuItems: NativeStackHeaderItemMenuAction[] = filterOptions.map((option) => { + const isSelected = activeFilter === option.key; + return { + type: "action", + label: option.label, + icon: { + name: + option.key === "upcoming" + ? "calendar.badge.clock" + : option.key === "unconfirmed" + ? "calendar.badge.exclamationmark" + : option.key === "past" + ? "calendar.badge.checkmark" + : "calendar.badge.minus", + type: "sfSymbol", + }, + state: isSelected ? "on" : "off", + onPress: () => { + handleFilterChange(option.key); + }, + } satisfies NativeStackHeaderItemMenuAction; + }); + + return { + type: "menu" as const, + label: currentFilterOption?.label || "Filter", + labelStyle: { + fontWeight: "600", + color: "#007AFF", + }, + menu: { + title: "Filter by Status", + items: statusMenuItems, + }, + }; + }; + + const buildEventTypeFilterMenu = () => { + const eventTypeMenuItems: NativeStackHeaderItemMenuAction[] = (eventTypes || []).map( + (eventType) => { + const isSelected = selectedEventTypeId === eventType.id; + return { + type: "action", + label: eventType.title, + icon: { + name: "calendar.badge.clock", + type: "sfSymbol", + }, + state: isSelected ? "on" : "off", + onPress: () => { + // Toggle behavior: if already selected, clear filter; otherwise, select it + if (isSelected) { + clearEventTypeFilter(); + } else { + setSelectedEventTypeId(eventType.id); + } + }, + }; + } + ); + + const clearFilterItem: NativeStackHeaderItemMenuAction[] = + selectedEventTypeId !== null + ? [ + { + type: "action", + label: "Clear filter", + icon: { + name: "xmark.circle", + type: "sfSymbol", + }, + onPress: () => { + clearEventTypeFilter(); + }, + }, + ] + : []; + + const menuItems = [...eventTypeMenuItems, ...clearFilterItem]; + + return { + type: "menu" as const, + label: "Filter by Event Type", + icon: { + name: "line.3.horizontal.decrease", + type: "sfSymbol", + }, + labelStyle: { + fontWeight: "600", + color: "#007AFF", + }, + menu: { + title: menuItems.length > 0 ? "Filter by Event Type" : "No Event Types", + items: [ + ...menuItems, + // Show message if no event types available + ...(menuItems.length === 0 + ? [ + { + type: "action", + label: "No event types available", + onPress: () => {}, + } satisfies NativeStackHeaderItemMenuAction, + ] + : []), + ], + }, + }; + }; + + return ( + <> + handleSearch(e.nativeEvent.text), + obscureBackground: false, + barTintColor: "#fff", + }, + unstable_headerRightItems: () => { + const eventTypeFilterMenu = buildEventTypeFilterMenu(); + const statusFilterMenu = buildActiveBookingFilter(); + + return [eventTypeFilterMenu, statusFilterMenu]; + }, + }} + /> + + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/index.tsx b/companion/app/(tabs)/(bookings)/index.tsx new file mode 100644 index 0000000000..a561402882 --- /dev/null +++ b/companion/app/(tabs)/(bookings)/index.tsx @@ -0,0 +1,153 @@ +import { Ionicons } from "@expo/vector-icons"; +import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; +import SegmentedControl from "@react-native-segmented-control/segmented-control"; +import React, { useState } from "react"; +import { View, Text, TouchableOpacity, TextInput } from "react-native"; + +import type { EventType } from "../../../services/calcom"; +import { CalComAPIService } from "../../../services/calcom"; +import { Header } from "../../../components/Header"; +import { BookingListScreen } from "../../../components/booking-list-screen/BookingListScreen"; +import { useActiveBookingFilter } from "../../../hooks/useActiveBookingFilter"; + +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 the active booking filter hook + const { activeFilter, filterLabels, activeIndex, filterParams, handleSegmentChange } = + useActiveBookingFilter("upcoming", () => { + // Clear dependent filters when status filter changes + setSearchQuery(""); + setSelectedEventTypeId(null); + setSelectedEventTypeLabel(null); + }); + + const handleSearch = (query: string) => { + setSearchQuery(query); + }; + + const fetchEventTypes = async () => { + try { + setEventTypesLoading(true); + const types = await CalComAPIService.getEventTypes(); + setEventTypes(types); + } catch (err) { + console.error("Error fetching event types:", err); + // Error is logged but not displayed to user for event type filter + } finally { + setEventTypesLoading(false); + } + }; + + const handleFilterButtonPress = () => { + setShowFilterModal(true); + if (eventTypes.length === 0) { + fetchEventTypes(); + } + }; + + const clearEventTypeFilter = () => { + setSelectedEventTypeId(null); + setSelectedEventTypeLabel(null); + }; + + const handleEventTypeSelect = (eventTypeId: number | null, label?: string | null) => { + if (eventTypeId === null) { + clearEventTypeFilter(); + } else { + setSelectedEventTypeId(eventTypeId); + setSelectedEventTypeLabel(label || null); + } + setShowFilterModal(false); + }; + + const supportsLiquidGlass = isLiquidGlassAvailable(); + + const renderSegmentedControl = () => { + const segmentedControlContent = ( + + ); + + return ( + <> + {supportsLiquidGlass ? ( + + {segmentedControlContent} + + ) : ( + + {segmentedControlContent} + + )} + + + + + + Filter + + + + + + + {selectedEventTypeId !== null ? ( + + + Filtered by {selectedEventTypeLabel || "event type"} + + + Clear filter + + + ) : null} + + + ); + }; + + return ( +
} + renderFilterControls={renderSegmentedControl} + showFilterModal={showFilterModal} + setShowFilterModal={setShowFilterModal} + eventTypes={eventTypes} + eventTypesLoading={eventTypesLoading} + searchQuery={searchQuery} + selectedEventTypeId={selectedEventTypeId} + onEventTypeChange={handleEventTypeSelect} + activeFilter={activeFilter} + filterParams={filterParams} + /> + ); +} diff --git a/companion/app/(tabs)/(event-types)/_layout.tsx b/companion/app/(tabs)/(event-types)/_layout.tsx index 92daf791ef..ca52620628 100644 --- a/companion/app/(tabs)/(event-types)/_layout.tsx +++ b/companion/app/(tabs)/(event-types)/_layout.tsx @@ -3,7 +3,7 @@ import { Stack } from "expo-router"; export default function EventTypesLayout() { return ( - + ); } diff --git a/companion/app/(tabs)/_layout.tsx b/companion/app/(tabs)/_layout.tsx index 14705ceba4..1bb7fc2a93 100644 --- a/companion/app/(tabs)/_layout.tsx +++ b/companion/app/(tabs)/_layout.tsx @@ -20,7 +20,7 @@ export default function TabLayout() { Event Types - + } @@ -76,7 +76,7 @@ function WebTabs() { /> ( @@ -86,7 +86,7 @@ function WebTabs() { /> ( diff --git a/companion/app/(tabs)/bookings.tsx b/companion/app/(tabs)/bookings.tsx deleted file mode 100644 index eae406da50..0000000000 --- a/companion/app/(tabs)/bookings.tsx +++ /dev/null @@ -1,1532 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; -import SegmentedControl from "@react-native-segmented-control/segmented-control"; -import { useRouter } from "expo-router"; -import React, { useState, useEffect, useMemo } from "react"; -import { - View, - Text, - FlatList, - ActivityIndicator, - Alert, - TouchableOpacity, - RefreshControl, - Platform, - TextInput, - ActionSheetIOS, - Linking, - Modal, - ScrollView, -} from "react-native"; -import type { NativeSyntheticEvent } from "react-native"; - -import { CalComAPIService, Booking, EventType } from "../../services/calcom"; -import { Header } from "../../components/Header"; -import { FullScreenModal } from "../../components/FullScreenModal"; -import { LoadingSpinner } from "../../components/LoadingSpinner"; -import { BookingActionsModal } from "../../components/BookingActionsModal"; -import { EmptyScreen } from "../../components/EmptyScreen"; -import { SvgImage } from "../../components/SvgImage"; -import { getAppIconUrl } from "../../utils/getAppIconUrl"; -import { useAuth } from "../../contexts/AuthContext"; -import { - useBookings, - useCancelBooking, - useConfirmBooking, - useDeclineBooking, - useRescheduleBooking, -} from "../../hooks"; -import { showErrorAlert } from "../../utils/alerts"; -import { offlineAwareRefresh } from "../../utils/network"; -import { openInAppBrowser } from "../../utils/browser"; - -type BookingFilter = "upcoming" | "unconfirmed" | "past" | "cancelled"; - -// Helper to extract clean meeting URL from potentially wrapped URLs -const extractMeetingUrl = (location: string): string => { - // Check if it's a goo.gl redirect URL with embedded meet.google.com link - if (location.includes("goo.gl") && location.includes("meet.google.com")) { - // Extract the actual meet.google.com URL from the redirect - const meetMatch = location.match(/meet\.google\.com\/[a-z]+-[a-z]+-[a-z]+/i); - if (meetMatch) { - return `https://${meetMatch[0]}`; - } - } - return location; -}; - -// Helper to detect meeting type from location URL and get icon/label -const getMeetingInfo = ( - location?: string -): { appId: string; label: string; iconUrl: string | null; cleanUrl: string } | null => { - if (!location) return null; - - // Check if it's a URL - if (!location.match(/^https?:\/\//)) return null; - - const lowerLocation = location.toLowerCase(); - const cleanUrl = extractMeetingUrl(location); - - // Cal Video - if (lowerLocation.includes("cal.com/video") || lowerLocation.includes("cal.video")) { - return { - appId: "cal-video", - label: "Join Cal Video", - iconUrl: getAppIconUrl("daily_video", "cal-video"), - cleanUrl, - }; - } - - // Google Meet (including goo.gl redirect URLs) - if ( - lowerLocation.includes("meet.google.com") || - (lowerLocation.includes("goo.gl") && lowerLocation.includes("meet")) - ) { - return { - appId: "google-meet", - label: "Join Google Meet", - iconUrl: getAppIconUrl("google_video", "google-meet"), - cleanUrl, - }; - } - - // Zoom - if (lowerLocation.includes("zoom.us") || lowerLocation.includes("zoom.com")) { - return { - appId: "zoom", - label: "Join Zoom", - iconUrl: getAppIconUrl("zoom_video", "zoom"), - cleanUrl, - }; - } - - // Microsoft Teams - if (lowerLocation.includes("teams.microsoft.com") || lowerLocation.includes("teams.live.com")) { - return { - appId: "msteams", - label: "Join Microsoft Teams", - iconUrl: getAppIconUrl("office365_video", "msteams"), - cleanUrl, - }; - } - - // Webex - if (lowerLocation.includes("webex.com")) { - return { - appId: "webex", - label: "Join Webex", - iconUrl: getAppIconUrl("webex_video", "webex"), - cleanUrl, - }; - } - - // Jitsi - if (lowerLocation.includes("meet.jit.si") || lowerLocation.includes("jitsi")) { - return { - appId: "jitsi", - label: "Join Jitsi", - iconUrl: getAppIconUrl("jitsi_video", "jitsi"), - cleanUrl, - }; - } - - // Not a recognized conferencing app - return null; -}; - -export default function Bookings() { - const router = useRouter(); - const { userInfo } = useAuth(); - const [searchQuery, setSearchQuery] = useState(""); - const [activeFilter, setActiveFilter] = useState("upcoming"); - const [showFilterModal, setShowFilterModal] = useState(false); - const [eventTypes, setEventTypes] = useState([]); - const [selectedEventTypeId, setSelectedEventTypeId] = useState(null); - const [selectedEventTypeLabel, setSelectedEventTypeLabel] = useState(null); - const [eventTypesLoading, setEventTypesLoading] = useState(false); - const [showBookingActionsModal, setShowBookingActionsModal] = useState(false); - const [selectedBooking, setSelectedBooking] = useState(null); - const [showRescheduleModal, setShowRescheduleModal] = useState(false); - const [rescheduleBooking, setRescheduleBooking] = useState(null); - const [rescheduleDate, setRescheduleDate] = useState(""); - const [rescheduleTime, setRescheduleTime] = useState(""); - const [rescheduleReason, setRescheduleReason] = useState(""); - const [showRejectModal, setShowRejectModal] = useState(false); - const [rejectBooking, setRejectBooking] = useState(null); - const [rejectReason, setRejectReason] = useState(""); - - const filterOptions: { key: BookingFilter; label: string }[] = [ - { key: "upcoming", label: "Upcoming" }, - { key: "unconfirmed", label: "Unconfirmed" }, - { key: "past", label: "Past" }, - { key: "cancelled", label: "Cancelled" }, - ]; - - const filterLabels = filterOptions.map((option) => option.label); - const activeIndex = filterOptions.findIndex((option) => option.key === activeFilter); - - // Get filters for the active tab - const getFiltersForActiveTab = () => { - switch (activeFilter) { - case "upcoming": - return { status: ["upcoming"], limit: 50 }; - case "unconfirmed": - return { status: ["unconfirmed"], limit: 50 }; - case "past": - return { status: ["past"], limit: 100 }; - case "cancelled": - return { status: ["cancelled"], limit: 100 }; - default: - return { status: ["upcoming"], limit: 50 }; - } - }; - - // Use React Query hook for fetching bookings - const { - data: rawBookings = [], - isLoading: loading, - isFetching, - error: queryError, - refetch, - } = useBookings(getFiltersForActiveTab()); - - // Show refresh indicator when fetching - const refreshing = isFetching && !loading; - - // Cancel booking mutation - const { mutate: cancelBookingMutation } = useCancelBooking(); - - // Confirm booking mutation - const { mutate: confirmBookingMutation, isPending: isConfirming } = useConfirmBooking(); - - // Decline booking mutation - const { mutate: declineBookingMutation, isPending: isDeclining } = useDeclineBooking(); - - // Reschedule booking mutation - const { mutate: rescheduleBookingMutation, isPending: isRescheduling } = useRescheduleBooking(); - - // Sort bookings based on active filter - const bookings = useMemo(() => { - if (!rawBookings || !Array.isArray(rawBookings)) return []; - - const sorted = [...rawBookings]; - switch (activeFilter) { - case "upcoming": - case "unconfirmed": - // Sort by start time ascending - return sorted.sort( - (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() - ); - case "past": - case "cancelled": - // Sort by start time descending (latest first) - return sorted.sort( - (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() - ); - default: - return sorted; - } - }, [rawBookings, activeFilter]); - - // Convert query error to string - // Don't show error UI for authentication errors (user will be redirected to login) - // Only show error UI in development mode for other errors - const isAuthError = - queryError?.message?.includes("Authentication") || - queryError?.message?.includes("sign in") || - queryError?.message?.includes("401"); - const error = queryError && !isAuthError && __DEV__ ? "Failed to load bookings." : null; - - // Clear search and event type filter when status filter changes - useEffect(() => { - setSearchQuery(""); - setSelectedEventTypeId(null); - setSelectedEventTypeLabel(null); - }, [activeFilter]); - - // Handle pull-to-refresh (offline-aware) - const onRefresh = () => offlineAwareRefresh(refetch); - - // Apply local filters (search and event type) using useMemo - const filteredBookings = useMemo(() => { - let filtered = bookings; - - // Apply event type filter - if (selectedEventTypeId !== null) { - filtered = filtered.filter((booking) => booking.eventTypeId === selectedEventTypeId); - } - - // Apply search filter - if (searchQuery.trim() !== "") { - const searchLower = searchQuery.toLowerCase(); - filtered = filtered.filter( - (booking) => - // Search in booking title - booking.title?.toLowerCase().includes(searchLower) || - // Search in booking description - booking.description?.toLowerCase().includes(searchLower) || - // Search in event type title - booking.eventType?.title?.toLowerCase().includes(searchLower) || - // Search in attendee names - (booking.attendees && - booking.attendees.some((attendee) => - attendee.name?.toLowerCase().includes(searchLower) - )) || - // Search in attendee emails - (booking.attendees && - booking.attendees.some((attendee) => - attendee.email?.toLowerCase().includes(searchLower) - )) || - // Search in location - booking.location?.toLowerCase().includes(searchLower) || - // Search in user name - booking.user?.name?.toLowerCase().includes(searchLower) || - // Search in user email - booking.user?.email?.toLowerCase().includes(searchLower) - ); - } - - return filtered; - }, [bookings, searchQuery, selectedEventTypeId]); - - const handleSearch = (query: string) => { - setSearchQuery(query); - }; - - const fetchEventTypes = async () => { - try { - setEventTypesLoading(true); - const types = await CalComAPIService.getEventTypes(); - setEventTypes(types); - } catch (err) { - console.error("Error fetching event types:", err); - // Error is logged but not displayed to user for event type filter - } finally { - setEventTypesLoading(false); - } - }; - - const handleFilterButtonPress = () => { - setShowFilterModal(true); - if (eventTypes.length === 0) { - fetchEventTypes(); - } - }; - - const clearEventTypeFilter = () => { - setSelectedEventTypeId(null); - setSelectedEventTypeLabel(null); - }; - - const handleEventTypeSelect = (eventTypeId: number | null, label?: string | null) => { - if (eventTypeId === null) { - clearEventTypeFilter(); - } else { - setSelectedEventTypeId(eventTypeId); - setSelectedEventTypeLabel(label || null); - } - setShowFilterModal(false); - }; - - const handleFilterChange = (filter: BookingFilter) => { - setActiveFilter(filter); - }; - - const handleSegmentChange = (event: NativeSyntheticEvent<{ selectedSegmentIndex: number }>) => { - const { selectedSegmentIndex } = event.nativeEvent; - const selectedFilter = filterOptions[selectedSegmentIndex]; - if (selectedFilter) { - handleFilterChange(selectedFilter.key); - } - }; - - const getEmptyStateContent = () => { - switch (activeFilter) { - case "upcoming": - return { - icon: "calendar-outline" as const, - title: "No upcoming bookings", - text: "As soon as someone books a time with you it will show up here.", - }; - case "unconfirmed": - return { - icon: "calendar-outline" as const, - title: "No unconfirmed bookings", - text: "Your unconfirmed bookings will show up here.", - }; - case "past": - return { - icon: "calendar-outline" as const, - title: "No past bookings", - text: "Your past bookings will show up here.", - }; - case "cancelled": - return { - icon: "calendar-outline" as const, - title: "No cancelled bookings", - text: "Your canceled bookings will show up here.", - }; - default: - return { - icon: "calendar-outline" as const, - title: "No bookings found", - text: "Your bookings will appear here.", - }; - } - }; - - const supportsLiquidGlass = isLiquidGlassAvailable(); - - const renderSegmentedControl = () => { - const segmentedControlContent = ( - - ); - - return ( - <> - {supportsLiquidGlass ? ( - - {segmentedControlContent} - - ) : ( - - {segmentedControlContent} - - )} - - - - - - Filter - - - - - - - {selectedEventTypeId !== null ? ( - - - Filtered by {selectedEventTypeLabel || "event type"} - - - Clear filter - - - ) : null} - - - ); - }; - - const handleBookingPress = (booking: Booking) => { - router.push({ - pathname: "/booking-detail", - params: { uid: booking.uid }, - }); - }; - - const handleBookingPressOld = (booking: Booking) => { - if (Platform.OS !== "ios") { - // Fallback for non-iOS platforms - const attendeesList = booking.attendees?.map((att) => att.name).join(", ") || "No attendees"; - const startTime = booking.start || booking.startTime || ""; - const endTime = booking.end || booking.endTime || ""; - - const actions = getBookingActions(booking); - const alertActions = actions.map((action) => ({ - text: action.title, - style: (action.destructive ? "destructive" : "default") as - | "destructive" - | "default" - | "cancel", - onPress: action.onPress, - })); - alertActions.unshift({ text: "Cancel", style: "cancel" as const, onPress: () => {} }); - - Alert.alert( - booking.title, - `${booking.description ? `${booking.description}\n\n` : ""}Time: ${formatDateTime( - startTime - )} - ${formatTime(endTime)}\nAttendees: ${attendeesList}\nStatus: ${booking.status}${ - booking.location ? `\nLocation: ${booking.location}` : "" - }`, - alertActions - ); - return; - } - - const actions = getBookingActions(booking); - const options = ["Cancel", ...actions.map((action) => action.title)]; - const destructiveButtonIndex = actions.findIndex((action) => action.destructive); - - ActionSheetIOS.showActionSheetWithOptions( - { - options, - destructiveButtonIndex: - destructiveButtonIndex >= 0 ? destructiveButtonIndex + 1 : undefined, - cancelButtonIndex: 0, - title: booking.title, - message: getBookingDetailsMessage(booking), - }, - (buttonIndex) => { - if (buttonIndex === 0) return; // Cancel - - const actionIndex = buttonIndex - 1; - if (actions[actionIndex]) { - actions[actionIndex].onPress(); - } - } - ); - }; - - const getBookingDetailsMessage = (booking: Booking): string => { - const attendeesList = booking.attendees?.map((att) => att.name).join(", ") || "No attendees"; - const startTime = booking.start || booking.startTime || ""; - const endTime = booking.end || booking.endTime || ""; - - return `${booking.description ? `${booking.description}\n\n` : ""}Time: ${formatDateTime( - startTime - )} - ${formatTime(endTime)}\nAttendees: ${attendeesList}\nStatus: ${formatStatusText(booking.status)}${ - booking.location ? `\nLocation: ${booking.location}` : "" - }`; - }; - - const getBookingActions = (booking: Booking) => { - const baseActions = [ - { - title: "Open Location", - onPress: () => handleOpenLocation(booking), - destructive: false, - }, - ]; - - const specificActions = (() => { - switch (activeFilter) { - case "upcoming": - return [ - { - title: "Request Reschedule", - onPress: () => handleRescheduleBooking(booking), - destructive: false, - }, - { - title: "Cancel event", - onPress: () => handleCancelEvent(booking), - destructive: true, - }, - ]; - case "unconfirmed": - return [ - { - title: "Confirm booking", - onPress: () => handleConfirmBooking(booking), - destructive: false, - }, - { - title: "Decline booking", - onPress: () => handleRejectBooking(booking), - destructive: true, - }, - ]; - case "past": - return []; - case "cancelled": - return []; - default: - return []; - } - })(); - - return [...baseActions, ...specificActions]; - }; - - const handleOpenLocation = async (booking: Booking) => { - if (!booking.location) { - Alert.alert("No Location", "This booking doesn't have a location set."); - return; - } - - try { - // Check if location is a URL (starts with http:// or https://) - if (booking.location.match(/^https?:\/\//)) { - // Open web URLs in in-app browser - await openInAppBrowser(booking.location, "meeting link"); - } else { - // If it's not a URL, try to open it as a location in maps - const mapsUrl = - Platform.OS === "ios" - ? `maps://maps.apple.com/?q=${encodeURIComponent(booking.location)}` - : `geo:0,0?q=${encodeURIComponent(booking.location)}`; - - const supported = await Linking.canOpenURL(mapsUrl); - if (supported) { - await Linking.openURL(mapsUrl); - } else { - // Fallback to Google Maps in in-app browser - const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent( - booking.location - )}`; - await openInAppBrowser(googleMapsUrl, "Google Maps"); - } - } - } catch (error) { - showErrorAlert("Error", "Failed to open location. Please try again."); - } - }; - - const handleRescheduleBooking = (booking: Booking) => { - // Pre-fill with the current booking date/time - const currentDate = new Date(booking.startTime); - const dateStr = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD - const timeStr = currentDate.toTimeString().slice(0, 5); // HH:MM - - setRescheduleBooking(booking); - setRescheduleDate(dateStr); - setRescheduleTime(timeStr); - setRescheduleReason(""); - setShowRescheduleModal(true); - }; - - const handleSubmitReschedule = () => { - if (!rescheduleBooking || !rescheduleDate || !rescheduleTime) { - showErrorAlert("Error", "Please enter both date and time"); - return; - } - - // Parse the date and time - const dateTimeStr = `${rescheduleDate}T${rescheduleTime}:00`; - const newDateTime = new Date(dateTimeStr); - - // Validate the date - if (isNaN(newDateTime.getTime())) { - showErrorAlert( - "Error", - "Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time." - ); - return; - } - - // Check if the new time is in the future - if (newDateTime <= new Date()) { - showErrorAlert("Error", "Please select a future date and time"); - return; - } - - // Convert to UTC ISO string - const startUtc = newDateTime.toISOString(); - - rescheduleBookingMutation( - { - uid: rescheduleBooking.uid, - start: startUtc, - reschedulingReason: rescheduleReason || undefined, - }, - { - onSuccess: () => { - setShowRescheduleModal(false); - setRescheduleBooking(null); - Alert.alert("Success", "Booking rescheduled successfully"); - }, - onError: (error) => { - showErrorAlert("Error", error.message || "Failed to reschedule booking"); - }, - } - ); - }; - - const handleCancelEvent = (booking: Booking) => { - Alert.alert("Cancel Event", `Are you sure you want to cancel "${booking.title}"?`, [ - { text: "Cancel", style: "cancel" }, - { - text: "Cancel Event", - style: "destructive", - onPress: () => { - // Prompt for cancellation reason - Alert.prompt( - "Cancellation Reason", - "Please provide a reason for cancelling this booking:", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Cancel Event", - style: "destructive", - onPress: (reason) => { - const cancellationReason = reason?.trim() || "Event cancelled by host"; - cancelBookingMutation( - { uid: booking.uid, reason: cancellationReason }, - { - onSuccess: () => { - Alert.alert("Success", "Event cancelled successfully"); - }, - onError: (error) => { - console.error("Failed to cancel booking:", error); - showErrorAlert("Error", "Failed to cancel event. Please try again."); - }, - } - ); - }, - }, - ], - "plain-text", - "", - "default" - ); - }, - }, - ]); - }; - - const handleConfirmBooking = (booking: Booking) => { - Alert.alert("Confirm Booking", `Are you sure you want to confirm "${booking.title}"?`, [ - { text: "Cancel", style: "cancel" }, - { - text: "Confirm", - onPress: () => { - confirmBookingMutation( - { uid: booking.uid }, - { - onSuccess: () => { - Alert.alert("Success", "Booking confirmed successfully"); - }, - onError: (error) => { - showErrorAlert("Error", error.message || "Failed to confirm booking"); - }, - } - ); - }, - }, - ]); - }; - - const handleRejectBooking = (booking: Booking) => { - Alert.alert("Decline Booking", `Are you sure you want to decline "${booking.title}"?`, [ - { text: "Cancel", style: "cancel" }, - { - text: "Decline", - style: "destructive", - onPress: () => { - // Show optional reason input - Alert.prompt( - "Decline Reason", - "Optionally provide a reason for declining (press OK to skip)", - [ - { text: "Cancel", style: "cancel" }, - { - text: "OK", - onPress: (reason?: string) => { - declineBookingMutation( - { uid: booking.uid, reason: reason || undefined }, - { - onSuccess: () => { - Alert.alert("Success", "Booking declined successfully"); - }, - onError: (error) => { - showErrorAlert("Error", error.message || "Failed to decline booking"); - }, - } - ); - }, - }, - ], - "plain-text", - "", - "default" - ); - }, - }, - ]); - }; - - const formatDateTime = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); - }; - - const formatTime = (dateString: string) => { - if (!dateString) { - return ""; - } - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) { - console.warn("Invalid date string:", dateString); - return ""; - } - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); - } catch (error) { - console.error("Error formatting time:", error, dateString); - return ""; - } - }; - - const formatDate = (dateString: string, isUpcoming: boolean) => { - if (!dateString) { - return ""; - } - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) { - console.warn("Invalid date string:", dateString); - return ""; - } - const bookingYear = date.getFullYear(); - const currentYear = new Date().getFullYear(); - const isDifferentYear = bookingYear !== currentYear; - - if (isUpcoming) { - // Format: "ddd, D MMM" or "ddd, D MMM YYYY" if different year - const weekday = date.toLocaleDateString("en-US", { weekday: "short" }); - const day = date.getDate(); - const month = date.toLocaleDateString("en-US", { month: "short" }); - if (isDifferentYear) { - return `${weekday}, ${day} ${month} ${bookingYear}`; - } - return `${weekday}, ${day} ${month}`; - } else { - // Format: "D MMMM YYYY" for past bookings - return date.toLocaleDateString("en-US", { - day: "numeric", - month: "long", - year: "numeric", - }); - } - } catch (error) { - console.error("Error formatting date:", error, dateString); - return ""; - } - }; - - const getStatusColor = (status: string) => { - // API v2 2024-08-13 returns status in lowercase, so normalize to uppercase for comparison - const normalizedStatus = status.toUpperCase(); - switch (normalizedStatus) { - case "ACCEPTED": - return "#34C759"; - case "PENDING": - return "#FF9500"; - case "CANCELLED": - return "#FF3B30"; - case "REJECTED": - return "#FF3B30"; - default: - return "#666"; - } - }; - - const formatStatusText = (status: string) => { - // Capitalize first letter for display - return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); - }; - - const formatMonthYear = (dateString: string): string => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) { - return ""; - } - const now = new Date(); - const isCurrentMonth = - date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth(); - - if (isCurrentMonth) { - return "This month"; - } - - return date.toLocaleDateString("en-US", { - month: "long", - year: "numeric", - }); - } catch (error) { - return ""; - } - }; - - const getMonthYearKey = (dateString: string): string => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) { - return ""; - } - return `${date.getFullYear()}-${date.getMonth()}`; - } catch (error) { - return ""; - } - }; - - type ListItem = - | { type: "monthHeader"; monthYear: string; key: string } - | { type: "booking"; booking: Booking; key: string }; - - const groupBookingsByMonth = (bookings: Booking[]): ListItem[] => { - const grouped: ListItem[] = []; - let currentMonthYear: string | null = null; - - bookings.forEach((booking) => { - const startTime = booking.start || booking.startTime || ""; - if (!startTime) return; - - const monthYearKey = getMonthYearKey(startTime); - const monthYear = formatMonthYear(startTime); - - if (monthYearKey !== currentMonthYear) { - currentMonthYear = monthYearKey; - grouped.push({ - type: "monthHeader", - monthYear, - key: `month-${monthYearKey}`, - }); - } - - grouped.push({ - type: "booking", - booking, - key: booking.id.toString(), - }); - }); - - return grouped; - }; - - const renderBooking = ({ item }: { item: Booking }) => { - const startTime = item.start || item.startTime || ""; - const endTime = item.end || item.endTime || ""; - const isUpcoming = new Date(endTime) >= new Date(); - const isPending = item.status?.toUpperCase() === "PENDING"; - const isCancelled = item.status?.toUpperCase() === "CANCELLED"; - const isRejected = item.status?.toUpperCase() === "REJECTED"; - - const getHostAndAttendeesDisplay = () => { - const hasHostOrAttendees = - (item.hosts && item.hosts.length > 0) || - item.user || - (item.attendees && item.attendees.length > 0); - - if (!hasHostOrAttendees) return null; - - const currentUserEmail = userInfo?.email?.toLowerCase(); - const hostEmail = item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase(); - const isCurrentUserHost = currentUserEmail && hostEmail && currentUserEmail === hostEmail; - - const hostName = isCurrentUserHost - ? "You" - : item.hosts?.[0]?.name || item.hosts?.[0]?.email || item.user?.name || item.user?.email; - - const attendeesDisplay = - item.attendees && item.attendees.length > 0 - ? item.attendees.length === 1 - ? item.attendees[0].name || item.attendees[0].email - : item.attendees - .slice(0, 2) - .map((att) => att.name || att.email) - .join(", ") + - (item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "") - : null; - - if (hostName && attendeesDisplay) { - return `${hostName} and ${attendeesDisplay}`; - } else if (hostName) { - return hostName; - } else if (attendeesDisplay) { - return attendeesDisplay; - } - return null; - }; - - const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(); - const meetingInfo = getMeetingInfo(item.location); - - return ( - - handleBookingPress(item)} - onLongPress={() => { - setSelectedBooking(item); - setShowBookingActionsModal(true); - }} - style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }} - > - {/* Time and Date Row */} - - - {formatDate(startTime, isUpcoming)} - - - {formatTime(startTime)} - {formatTime(endTime)} - - - {/* Badges Row */} - - {isPending ? ( - - Unconfirmed - - ) : null} - - {/* Title */} - - {item.title} - - {/* Description */} - {item.description ? ( - - "{item.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} - - - {isPending ? ( - <> - { - e.stopPropagation(); - confirmBookingMutation( - { uid: item.uid }, - { - onSuccess: () => { - Alert.alert("Success", "Booking confirmed successfully"); - }, - onError: (error) => { - showErrorAlert("Error", "Failed to confirm booking. Please try again."); - }, - } - ); - }} - > - - Confirm - - { - e.stopPropagation(); - setRejectBooking(item); - setRejectReason(""); - setShowRejectModal(true); - }} - > - - Reject - - - ) : null} - { - e.stopPropagation(); - setSelectedBooking(item); - setShowBookingActionsModal(true); - }} - > - - - - - ); - }; - - const renderListItem = ({ item }: { item: ListItem }) => { - if (item.type === "monthHeader") { - return ( - - {item.monthYear} - - ); - } - return renderBooking({ item: item.booking }); - }; - - if (loading) { - return ( - -
- {renderSegmentedControl()} - - - - - ); - } - - if (error) { - return ( - -
- {renderSegmentedControl()} - - - - Unable to load bookings - - {error} - refetch()}> - Retry - - - - ); - } - - // Determine what content to show - const showEmptyState = bookings.length === 0 && !loading; - const showSearchEmptyState = - filteredBookings.length === 0 && searchQuery.trim() !== "" && !loading && !showEmptyState; - const showList = !showEmptyState && !showSearchEmptyState && !loading; - const emptyState = getEmptyStateContent(); - - return ( - -
- {renderSegmentedControl()} - - {/* Empty state - no bookings */} - {showEmptyState ? ( - - } - > - - - - ) : null} - - {/* Search empty state */} - {showSearchEmptyState ? ( - - } - > - - - - ) : null} - - {/* Bookings list */} - {showList ? ( - - - item.key} - renderItem={renderListItem} - contentContainerStyle={{ paddingBottom: 90 }} - refreshControl={} - showsVerticalScrollIndicator={false} - /> - - - ) : null} - - {/* Filter Modal */} - setShowFilterModal(false)} - > - setShowFilterModal(false)} - > - e.stopPropagation()} - className="w-[85%] max-w-[350px] rounded-2xl bg-white p-5" - > - - Filter by Event Type - - - {eventTypesLoading ? ( - - - Loading event types... - - ) : ( - - {eventTypes.map((eventType) => ( - handleEventTypeSelect(eventType.id, eventType.title)} - > - - {eventType.title} - - {selectedEventTypeId === eventType.id ? ( - - ) : null} - - ))} - - {eventTypes.length === 0 ? ( - - No event types found - - ) : null} - - )} - - - - - {/* Booking Actions Modal */} - setShowBookingActionsModal(false)} - booking={selectedBooking} - hasLocationUrl={!!selectedBooking?.location} - isUpcoming={ - selectedBooking - ? new Date(selectedBooking.endTime || selectedBooking.end || "") >= new Date() && - selectedBooking.status?.toUpperCase() !== "PENDING" - : false - } - isPast={ - selectedBooking - ? new Date(selectedBooking.endTime || selectedBooking.end || "") < new Date() - : false - } - isCancelled={selectedBooking?.status?.toUpperCase() === "CANCELLED"} - isUnconfirmed={selectedBooking?.status?.toUpperCase() === "PENDING"} - onReschedule={() => { - if (selectedBooking) handleRescheduleBooking(selectedBooking); - }} - onEditLocation={() => { - Alert.alert("Edit Location", "Edit location functionality coming soon"); - }} - onAddGuests={() => { - Alert.alert("Add Guests", "Add guests functionality coming soon"); - }} - onViewRecordings={() => { - Alert.alert("View Recordings", "View recordings functionality coming soon"); - }} - onMeetingSessionDetails={() => { - Alert.alert( - "Meeting Session Details", - "Meeting session details functionality coming soon" - ); - }} - onMarkNoShow={() => { - Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon"); - }} - onReportBooking={() => { - Alert.alert("Report Booking", "Report booking functionality coming soon"); - }} - onCancelBooking={() => { - if (selectedBooking) handleCancelEvent(selectedBooking); - }} - /> - - {/* Reschedule Modal */} - { - setShowRescheduleModal(false); - setRescheduleBooking(null); - }} - > - - {rescheduleBooking ? ( - <> - - Reschedule "{rescheduleBooking.title}" - - - {/* Date Input */} - - - New Date (YYYY-MM-DD) - - - - - {/* Time Input */} - - - New Time (HH:MM, 24-hour format) - - - - - {/* Reason Input */} - - Reason (optional) - - - - {/* Submit Button */} - - {isRescheduling ? ( - - ) : ( - - Reschedule Booking - - )} - - - {/* Cancel Button */} - { - setShowRescheduleModal(false); - setRescheduleBooking(null); - }} - > - Cancel - - - ) : null} - - - - {/* Reject Booking Modal */} - { - setShowRejectModal(false); - setRejectBooking(null); - setRejectReason(""); - }} - > - { - setShowRejectModal(false); - setRejectBooking(null); - setRejectReason(""); - }} - > - e.stopPropagation()} - > - - {/* Title */} - - Reject the booking request? - - - {/* Description */} - - Are you sure you want to reject the booking? We'll let the person who tried to book - know. You can provide a reason below. - - - {/* Reason Input */} - - - Reason for rejecting (Optional) - - - - - {/* Separator */} - - - {/* Buttons Row */} - - {/* Close Button */} - { - setShowRejectModal(false); - setRejectBooking(null); - setRejectReason(""); - }} - > - Close - - - {/* Reject Button */} - { - if (rejectBooking) { - declineBookingMutation( - { uid: rejectBooking.uid, reason: rejectReason || undefined }, - { - onSuccess: () => { - setShowRejectModal(false); - setRejectBooking(null); - setRejectReason(""); - Alert.alert("Success", "Booking rejected successfully"); - }, - onError: (error) => { - showErrorAlert("Error", "Failed to reject booking. Please try again."); - }, - } - ); - } - }} - disabled={isDeclining} - style={{ opacity: isDeclining ? 0.5 : 1 }} - > - Reject the booking - - - - - - - - ); -} diff --git a/companion/components/booking-list-item/BookingListItem.ios.tsx b/companion/components/booking-list-item/BookingListItem.ios.tsx new file mode 100644 index 0000000000..09eca44244 --- /dev/null +++ b/companion/components/booking-list-item/BookingListItem.ios.tsx @@ -0,0 +1,262 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { View, Text, TouchableOpacity, Linking, Pressable, Alert } from "react-native"; +import { Host, ContextMenu, Button, Image, HStack } from "@expo/ui/swift-ui"; +import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; + +import type { BookingListItemProps } from "./types"; +import { SvgImage } from "../SvgImage"; +import { getMeetingInfo } from "../../utils/meetings-utils"; +import { formatTime, formatDate, getHostAndAttendeesDisplay } from "../../utils/bookings-utils"; +import { showErrorAlert } from "../../utils/alerts"; + +export const BookingListItem: React.FC = ({ + booking, + userEmail, + isConfirming, + isDeclining, + onPress, + onLongPress, + onConfirm, + onReject, + onActionsPress, + onReschedule, + onEditLocation, + onAddGuests, + onViewRecordings, + onMeetingSessionDetails, + onMarkNoShow, + onReportBooking, + onCancelBooking, +}) => { + const startTime = booking.start || booking.startTime || ""; + const endTime = booking.end || booking.endTime || ""; + const isUpcoming = new Date(endTime) >= new Date(); + const isPending = booking.status?.toUpperCase() === "PENDING"; + const isCancelled = booking.status?.toUpperCase() === "CANCELLED"; + const isRejected = booking.status?.toUpperCase() === "REJECTED"; + const hasLocationUrl = !!booking.location; + + const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail); + const meetingInfo = getMeetingInfo(booking.location); + + // Define context menu actions based on booking state + type ContextMenuAction = { + label: string; + icon: string; + onPress: () => void; + role: "default" | "destructive"; + }; + + const allActions: (ContextMenuAction & { visible: boolean })[] = [ + // Edit Event Section + { + label: "Reschedule Booking", + icon: "calendar", + onPress: () => onReschedule?.(booking), + role: "default", + visible: isUpcoming && !isCancelled && !isPending && !!onReschedule, + }, + { + label: "Edit Location", + icon: "location", + onPress: () => onEditLocation?.(booking), + role: "default", + visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation, + }, + { + label: "Add Guests", + icon: "person.badge.plus", + onPress: () => onAddGuests?.(booking), + role: "default", + visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests, + }, + // After Event Section + { + label: "View Recordings", + icon: "video", + onPress: () => onViewRecordings?.(booking), + role: "default", + visible: hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onViewRecordings, + }, + { + label: "Meeting Session Details", + icon: "info.circle", + onPress: () => onMeetingSessionDetails?.(booking), + role: "default", + visible: + hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onMeetingSessionDetails, + }, + { + label: "Mark as No-Show", + icon: "eye.slash", + onPress: () => onMarkNoShow?.(booking), + role: "default", + visible: !isUpcoming && !isPending && !!onMarkNoShow, + }, + // Other Actions + { + label: "Report Booking", + icon: "flag", + onPress: () => onReportBooking?.(booking), + role: "destructive", + visible: !!onReportBooking, + }, + { + label: "Cancel Event", + icon: "xmark.circle", + onPress: () => onCancelBooking?.(booking), + role: "destructive", + visible: isUpcoming && !isCancelled && !!onCancelBooking, + }, + ]; + + const contextMenuActions: ContextMenuAction[] = allActions + .filter((action) => action.visible) + .map(({ label, icon, onPress, role }) => ({ label, icon, onPress, role })); + + return ( + + onPress(booking)} + onLongPress={() => onLongPress(booking)} + style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }} + className="active:bg-[#F8F9FA]" + > + {/* Time and Date Row */} + + + {formatDate(startTime, isUpcoming)} + + + {formatTime(startTime)} - {formatTime(endTime)} + + + {/* Badges Row */} + + {isPending ? ( + + Unconfirmed + + ) : null} + + {/* 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} + + + {isPending ? ( + <> + { + e.stopPropagation(); + onConfirm(booking); + }} + > + + Confirm + + { + e.stopPropagation(); + onReject(booking); + }} + > + + Reject + + + ) : null} + + {/* iOS Context Menu */} + + + + {contextMenuActions.map((action) => ( +