From b8e34bdc065fcdc62a5027e252ca0ca8c2f36cf2 Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:57:48 +0530 Subject: [PATCH] feat(companion): new booking details page and long press feature for ios (#26264) * longpress ios - event type, booking, availability * new booking details page for ios * remove comments * address cubics comments * use lowercase type same as api v2 --- .../(tabs)/(bookings)/booking-detail.ios.tsx | 359 ++++++++++ companion/app/(tabs)/(event-types)/index.tsx | 2 +- companion/app/event-type-detail.tsx | 2 +- .../AvailabilityListItem.ios.tsx | 124 ++-- .../booking-list-item/BookingListItem.ios.tsx | 67 +- .../useBookingListItemData.ts | 6 +- .../booking-list-screen/BookingListScreen.tsx | 2 +- .../EventTypeListItem.ios.tsx | 115 +-- .../components/screens/AddGuestsScreen.tsx | 2 +- .../screens/AvailabilityDetailScreen.tsx | 6 +- .../screens/AvailabilityListScreen.tsx | 2 +- .../screens/BookingDetailScreen.ios.tsx | 677 ++++++++++++++++++ .../screens/BookingDetailScreen.tsx | 34 +- companion/hooks/useBookingActionsGating.ts | 8 +- companion/services/types/bookings.types.ts | 15 +- companion/utils/booking-actions.ts | 17 +- companion/utils/bookings-utils.ts | 10 +- 17 files changed, 1292 insertions(+), 156 deletions(-) create mode 100644 companion/app/(tabs)/(bookings)/booking-detail.ios.tsx create mode 100644 companion/components/screens/BookingDetailScreen.ios.tsx diff --git a/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx b/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx new file mode 100644 index 0000000000..5e221d92e3 --- /dev/null +++ b/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx @@ -0,0 +1,359 @@ +import * as Clipboard from "expo-clipboard"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef, useState } 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 { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions"; +import { openInAppBrowser } from "@/utils/browser"; + +// Empty actions result for when no booking is loaded +const EMPTY_ACTIONS: BookingActionsResult = { + reschedule: { visible: false, enabled: false }, + rescheduleRequest: { visible: false, enabled: false }, + cancel: { visible: false, enabled: false }, + changeLocation: { visible: false, enabled: false }, + addGuests: { visible: false, enabled: false }, + viewRecordings: { visible: false, enabled: false }, + meetingSessionDetails: { visible: false, enabled: false }, + markNoShow: { visible: false, enabled: false }, +}; + +// Type for action handlers exposed by BookingDetailScreen +type ActionHandlers = { + openRescheduleModal: () => void; + openEditLocationModal: () => void; + openAddGuestsModal: () => void; + openViewRecordingsModal: () => void; + openMeetingSessionDetailsModal: () => void; + openMarkNoShowModal: () => void; + handleCancelBooking: () => void; +}; + +// Get month name from date string +const getMonthName = (dateString: string | undefined): string => { + if (!dateString) return "Back"; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return "Back"; + return date.toLocaleDateString("en-US", { month: "long" }); +}; + +// Get meeting URL from booking +const getMeetingUrl = (booking: Booking | null): string | null => { + if (!booking) return null; + + // Check metadata for videoCallUrl first + const videoCallUrl = booking.responses?.videoCallUrl; + if (typeof videoCallUrl === "string" && videoCallUrl.startsWith("http")) { + return videoCallUrl; + } + + // Check location + const location = booking.location; + if (typeof location === "string" && location.startsWith("http")) { + return location; + } + + return null; +}; + +export default function BookingDetailIOS() { + "use no memo"; + const { uid } = useLocalSearchParams<{ uid: string }>(); + const { userInfo } = useAuth(); + const [booking, setBooking] = useState(null); + + // Ref to store action handlers from BookingDetailScreen + const actionHandlersRef = useRef(null); + + // Callback to receive action handlers from BookingDetailScreen + const handleActionsReady = useCallback((handlers: ActionHandlers) => { + actionHandlersRef.current = handlers; + }, []); + + // Fetch booking data for the iOS header menu actions + useEffect(() => { + if (uid) { + CalComAPIService.getBookingByUid(uid) + .then(setBooking) + .catch(() => { + // Error handling is done in BookingDetailScreen + }); + } + }, [uid]); + + // Get the month name from booking start date for back button + const monthName = useMemo(() => { + const startTime = booking?.start || booking?.startTime; + return getMonthName(startTime); + }, [booking?.start, booking?.startTime]); + + // Get meeting URL for Join button + const meetingUrl = useMemo(() => getMeetingUrl(booking), [booking]); + + // Handle join meeting + const handleJoinMeeting = useCallback(() => { + if (meetingUrl) { + openInAppBrowser(meetingUrl, "meeting link"); + } + }, [meetingUrl]); + + // Handle copy meeting link + const handleCopyMeetingLink = useCallback(async () => { + if (meetingUrl) { + await Clipboard.setStringAsync(meetingUrl); + Alert.alert("Copied", "Meeting link copied to clipboard"); + } + }, [meetingUrl]); + + // Compute actions using centralized gating (same as BookingDetailScreen) + const actions = useMemo(() => { + if (!booking) return EMPTY_ACTIONS; + return getBookingActions({ + booking, + eventType: undefined, // EventType not available in this context + currentUserId: userInfo?.id, + currentUserEmail: userInfo?.email, + isOnline: true, // Assume online for now + }); + }, [booking, userInfo?.id, userInfo?.email]); + + // Action handlers that use the booking data + const handleReschedule = useCallback(() => { + if (actionHandlersRef.current?.openRescheduleModal) { + actionHandlersRef.current.openRescheduleModal(); + } else { + Alert.alert("Error", "Unable to reschedule. Please try again."); + } + }, []); + + const handleEditLocation = useCallback(() => { + if (actionHandlersRef.current?.openEditLocationModal) { + actionHandlersRef.current.openEditLocationModal(); + } else { + Alert.alert("Error", "Unable to edit location. Please try again."); + } + }, []); + + const handleAddGuests = useCallback(() => { + if (actionHandlersRef.current?.openAddGuestsModal) { + actionHandlersRef.current.openAddGuestsModal(); + } else { + Alert.alert("Error", "Unable to add guests. Please try again."); + } + }, []); + + const handleViewRecordings = useCallback(() => { + if (actionHandlersRef.current?.openViewRecordingsModal) { + actionHandlersRef.current.openViewRecordingsModal(); + } else { + Alert.alert("Error", "Unable to view recordings. Please try again."); + } + }, []); + + const handleSessionDetails = useCallback(() => { + if (actionHandlersRef.current?.openMeetingSessionDetailsModal) { + actionHandlersRef.current.openMeetingSessionDetailsModal(); + } else { + Alert.alert("Error", "Unable to view session details. Please try again."); + } + }, []); + + const handleMarkNoShow = useCallback(() => { + if (actionHandlersRef.current?.openMarkNoShowModal) { + actionHandlersRef.current.openMarkNoShowModal(); + } else { + Alert.alert("Error", "Unable to mark no-show. Please try again."); + } + }, []); + + const handleReport = useCallback(() => { + Alert.alert("Report Booking", "Report booking functionality is not yet available"); + }, []); + + const handleCancel = useCallback(() => { + if (actionHandlersRef.current?.handleCancelBooking) { + actionHandlersRef.current.handleCancelBooking(); + } else { + Alert.alert("Error", "Unable to cancel. Please try again."); + } + }, []); + + // Define booking actions organized by sections + const bookingActionsSections = useMemo(() => { + const allEditEventActions = [ + { + id: "reschedule", + label: "Reschedule Booking", + icon: "calendar" as const, + onPress: handleReschedule, + gatingKey: "reschedule" as const, + }, + { + id: "edit-location", + label: "Edit Location", + icon: "location" as const, + onPress: handleEditLocation, + gatingKey: "changeLocation" as const, + }, + { + id: "add-guests", + label: "Add Guests", + icon: "person.badge.plus" as const, + onPress: handleAddGuests, + gatingKey: "addGuests" as const, + }, + ]; + + const allAfterEventActions = [ + { + id: "view-recordings", + label: "View Recordings", + icon: "video" as const, + onPress: handleViewRecordings, + gatingKey: "viewRecordings" as const, + }, + { + id: "session-details", + label: "Meeting Session Details", + icon: "info.circle" as const, + onPress: handleSessionDetails, + gatingKey: "meetingSessionDetails" as const, + }, + { + id: "mark-no-show", + label: "Mark as No-Show", + icon: "eye.slash" as const, + onPress: handleMarkNoShow, + gatingKey: "markNoShow" as const, + }, + ]; + + const allStandaloneActions = [ + { + id: "report", + label: "Report Booking", + icon: "flag" as const, + onPress: handleReport, + destructive: true, + gatingKey: null, + }, + { + id: "cancel", + label: "Cancel Event", + icon: "xmark.circle" as const, + onPress: handleCancel, + destructive: true, + gatingKey: "cancel" as const, + }, + ]; + + const filterAction = (action: { gatingKey: keyof typeof actions | null }) => { + if (action.gatingKey === null) return true; + const gating = actions[action.gatingKey]; + return gating.visible && gating.enabled; + }; + + return { + editEvent: allEditEventActions.filter(filterAction), + afterEvent: allAfterEventActions.filter(filterAction), + standalone: allStandaloneActions.filter(filterAction), + }; + }, [ + actions, + handleReschedule, + handleEditLocation, + handleAddGuests, + handleViewRecordings, + handleSessionDetails, + handleMarkNoShow, + handleReport, + handleCancel, + ]); + + return ( + <> + + + + + {/* Join Meeting Menu - only show if there's a meeting URL */} + {meetingUrl && ( + + + + Join Meeting + + + )} + + {/* Actions Menu */} + + + + {/* Copy Meeting Link - only show if there's a meeting URL */} + {meetingUrl && ( + + Copy Meeting Link + + )} + + {/* Edit Event Section */} + + {bookingActionsSections.editEvent.map((action) => ( + + {action.label} + + ))} + + + {/* After Event Section */} + + {bookingActionsSections.afterEvent.map((action) => ( + + {action.label} + + ))} + + + {/* Danger Zone Submenu */} + + {bookingActionsSections.standalone.map((action) => ( + + {action.label} + + ))} + + + + + + + + ); +} diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx index 7d3ce636f9..2507419036 100644 --- a/companion/app/(tabs)/(event-types)/index.tsx +++ b/companion/app/(tabs)/(event-types)/index.tsx @@ -395,7 +395,7 @@ export default function EventTypes() { {Platform.OS === "web" &&
} - + Unable to load event types diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx index 3671a7cc84..5461e0097b 100644 --- a/companion/app/event-type-detail.tsx +++ b/companion/app/event-type-detail.tsx @@ -2029,7 +2029,7 @@ export default function EventTypeDetail() { className="h-11 w-11 items-center justify-center" onPress={handleDelete} > - + diff --git a/companion/components/availability-list-item/AvailabilityListItem.ios.tsx b/companion/components/availability-list-item/AvailabilityListItem.ios.tsx index fc4b4471b1..1140f587a2 100644 --- a/companion/components/availability-list-item/AvailabilityListItem.ios.tsx +++ b/companion/components/availability-list-item/AvailabilityListItem.ios.tsx @@ -1,5 +1,5 @@ import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui"; -import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers"; +import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers"; import { isLiquidGlassAvailable } from "expo-glass-effect"; import { Pressable, View } from "react-native"; import type { SFSymbols7_0 } from "sf-symbols-typescript"; @@ -44,58 +44,82 @@ export const AvailabilityListItem = ({ ]; return ( - - - handleSchedulePress(schedule)} - className="mr-4 flex-1" - accessibilityRole="button" + + {/* Native iOS Context Menu for long-press */} + + - - - - - - - - {/* Three dots menu - fixed hit target so it doesn't get squeezed off-screen */} - - - + {scheduleActions.map((scheduleAction) => ( +