diff --git a/companion/app/(tabs)/(bookings)/_layout.tsx b/companion/app/(tabs)/(bookings)/_layout.tsx index 44898bcd33..77a440612d 100644 --- a/companion/app/(tabs)/(bookings)/_layout.tsx +++ b/companion/app/(tabs)/(bookings)/_layout.tsx @@ -6,6 +6,12 @@ export default function BookingsLayout() { + + + + + + ); } diff --git a/companion/app/(tabs)/(bookings)/add-guests.tsx b/companion/app/(tabs)/(bookings)/add-guests.tsx new file mode 100644 index 0000000000..4fc93c3f8c --- /dev/null +++ b/companion/app/(tabs)/(bookings)/add-guests.tsx @@ -0,0 +1,110 @@ +import { AppPressable } from "../../../components/AppPressable"; +import AddGuestsScreen from "../../../components/screens/AddGuestsScreen"; +import type { AddGuestsScreenHandle } from "../../../components/screens/AddGuestsScreen"; +import { CalComAPIService, type Booking } from "../../../services/calcom"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { Alert, ActivityIndicator, View, Text, Platform } from "react-native"; + +export default function AddGuests() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const [booking, setBooking] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const addGuestsScreenRef = useRef(null); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getBookingByUid(uid) + .then(setBooking) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const handleSave = useCallback(() => { + addGuestsScreenRef.current?.submit(); + }, []); + + const handleAddGuestsSuccess = useCallback(() => { + router.back(); + }, [router]); + + const renderHeaderRight = useCallback( + () => ( + + Save + + ), + [handleSave, isSaving] + ); + + if (isLoading) { + return ( + <> + + + {/* iOS-only Stack.Header */} + {Platform.OS === "ios" && ( + + Add Guests + + )} + + + + + + ); + } + + return ( + <> + + + {Platform.OS === "ios" && ( + + Add Guests + + + + Save + + + + )} + + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/booking-detail.tsx b/companion/app/(tabs)/(bookings)/booking-detail.tsx index 1a9d1e13f0..21a33754a6 100644 --- a/companion/app/(tabs)/(bookings)/booking-detail.tsx +++ b/companion/app/(tabs)/(bookings)/booking-detail.tsx @@ -1,75 +1,238 @@ import { BookingDetailScreen } from "../../../components/screens/BookingDetailScreen"; +import { useAuth } from "../../../contexts/AuthContext"; +import { useBookingActionModals } from "../../../hooks"; +import { CalComAPIService, type Booking } from "../../../services/calcom"; +import { getBookingActions, type BookingActionsResult } from "../../../utils/booking-actions"; import { Stack, useLocalSearchParams } from "expo-router"; -import React from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Alert } from "react-native"; +// 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; +}; + export default function BookingDetail() { const { uid } = useLocalSearchParams<{ uid: string }>(); + const { userInfo } = useAuth(); + const [booking, setBooking] = useState(null); - if (!uid) { - return 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]); + + // Booking action modals hook + const { selectedBooking: actionModalBooking } = useBookingActionModals(); + + // 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(() => { + // Use the action handler from BookingDetailScreen if available + if (actionHandlersRef.current?.openRescheduleModal) { + actionHandlersRef.current.openRescheduleModal(); + } else { + Alert.alert("Error", "Unable to reschedule. Please try again."); + } + }, []); + + const handleEditLocation = useCallback(() => { + // Use the action handler from BookingDetailScreen if available + if (actionHandlersRef.current?.openEditLocationModal) { + actionHandlersRef.current.openEditLocationModal(); + } else { + Alert.alert("Error", "Unable to edit location. Please try again."); + } + }, []); + + const handleAddGuests = useCallback(() => { + // Use the action handler from BookingDetailScreen if available + if (actionHandlersRef.current?.openAddGuestsModal) { + actionHandlersRef.current.openAddGuestsModal(); + } else { + Alert.alert("Error", "Unable to add guests. Please try again."); + } + }, []); + + const handleViewRecordings = useCallback(() => { + // Use the action handler from BookingDetailScreen if available + if (actionHandlersRef.current?.openViewRecordingsModal) { + actionHandlersRef.current.openViewRecordingsModal(); + } else { + Alert.alert("Error", "Unable to view recordings. Please try again."); + } + }, []); + + const handleSessionDetails = useCallback(() => { + // Use the action handler from BookingDetailScreen if available + if (actionHandlersRef.current?.openMeetingSessionDetailsModal) { + actionHandlersRef.current.openMeetingSessionDetailsModal(); + } else { + Alert.alert("Error", "Unable to view session details. Please try again."); + } + }, []); + + const handleMarkNoShow = useCallback(() => { + // Use the action handler from BookingDetailScreen if available + 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(() => { + // Use the action handler from BookingDetailScreen if available + 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 = { - editEvent: [ + // For iOS glass UI (header menu), we filter out actions where !visible || !enabled + // This matches the behavior in BookingListItem.ios.tsx context menu + const bookingActionsSections = useMemo(() => { + // All possible actions with their gating keys + const allEditEventActions = [ { id: "reschedule", label: "Reschedule Booking", - icon: "calendar", - onPress: () => Alert.alert("Reschedule", "Reschedule functionality coming soon"), + icon: "calendar" as const, + onPress: handleReschedule, + gatingKey: "reschedule" as const, }, { id: "edit-location", label: "Edit Location", - icon: "location", - onPress: () => Alert.alert("Edit Location", "Edit location functionality coming soon"), + icon: "location" as const, + onPress: handleEditLocation, + gatingKey: "changeLocation" as const, }, { id: "add-guests", label: "Add Guests", - icon: "person.badge.plus", - onPress: () => Alert.alert("Add Guests", "Add guests functionality coming soon"), + icon: "person.badge.plus" as const, + onPress: handleAddGuests, + gatingKey: "addGuests" as const, }, - ], - afterEvent: [ + ]; + + const allAfterEventActions = [ { id: "view-recordings", label: "View Recordings", - icon: "video", - onPress: () => Alert.alert("View Recordings", "View recordings functionality coming soon"), + icon: "video" as const, + onPress: handleViewRecordings, + gatingKey: "viewRecordings" as const, }, { id: "session-details", label: "Meeting Session Details", - icon: "info.circle", - onPress: () => - Alert.alert("Session Details", "Meeting session details functionality coming soon"), + icon: "info.circle" as const, + onPress: handleSessionDetails, + gatingKey: "meetingSessionDetails" as const, }, { id: "mark-no-show", label: "Mark as No-Show", - icon: "eye.slash", - onPress: () => Alert.alert("Mark No-Show", "Mark as no-show functionality coming soon"), + icon: "eye.slash" as const, + onPress: handleMarkNoShow, + gatingKey: "markNoShow" as const, }, - ], - standalone: [ + ]; + + const allStandaloneActions = [ { id: "report", label: "Report Booking", - icon: "flag", - onPress: () => Alert.alert("Report", "Report booking functionality coming soon"), + icon: "flag" as const, + onPress: handleReport, destructive: true, + // Report is always visible (no gating) + gatingKey: null, }, { id: "cancel", label: "Cancel Event", - icon: "xmark.circle", - onPress: () => Alert.alert("Cancel Event", "Cancel event functionality coming soon"), + icon: "xmark.circle" as const, + onPress: handleCancel, destructive: true, + gatingKey: "cancel" as const, }, - ], - } as const; + ]; + + // Filter actions based on gating (for glass UI, only show visible && enabled) + const filterAction = (action: { gatingKey: keyof typeof actions | null }) => { + if (action.gatingKey === null) return true; // No gating, always show + 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 ( <> @@ -131,7 +294,9 @@ export default function BookingDetail() { - + + + {/* Action Modals for iOS header menu */} ); } diff --git a/companion/app/(tabs)/(bookings)/edit-location.tsx b/companion/app/(tabs)/(bookings)/edit-location.tsx new file mode 100644 index 0000000000..8eeee16237 --- /dev/null +++ b/companion/app/(tabs)/(bookings)/edit-location.tsx @@ -0,0 +1,110 @@ +import { AppPressable } from "../../../components/AppPressable"; +import EditLocationScreen from "../../../components/screens/EditLocationScreen"; +import type { EditLocationScreenHandle } from "../../../components/screens/EditLocationScreen"; +import { CalComAPIService, type Booking } from "../../../services/calcom"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { Alert, ActivityIndicator, View, Text, Platform } from "react-native"; + +export default function EditLocation() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const [booking, setBooking] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const editLocationScreenRef = useRef(null); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getBookingByUid(uid) + .then(setBooking) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const handleSave = useCallback(() => { + editLocationScreenRef.current?.submit(); + }, []); + + const handleUpdateSuccess = useCallback(() => { + router.back(); + }, [router]); + + const renderHeaderRight = useCallback( + () => ( + + Save + + ), + [handleSave, isSaving] + ); + + if (isLoading) { + return ( + <> + + + {/* iOS-only Stack.Header */} + {Platform.OS === "ios" && ( + + Edit Location + + )} + + + + + + ); + } + + return ( + <> + + + {Platform.OS === "ios" && ( + + Edit Location + + + + Save + + + + )} + + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/mark-no-show.tsx b/companion/app/(tabs)/(bookings)/mark-no-show.tsx new file mode 100644 index 0000000000..a568d1a5eb --- /dev/null +++ b/companion/app/(tabs)/(bookings)/mark-no-show.tsx @@ -0,0 +1,115 @@ +import MarkNoShowScreen from "../../../components/screens/MarkNoShowScreen"; +import { CalComAPIService, type Booking } from "../../../services/calcom"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import React, { useState, useEffect } from "react"; +import { Alert, ActivityIndicator, View, Platform } from "react-native"; + +interface Attendee { + id?: number | string; + email: string; + name: string; + noShow?: boolean; +} + +export default function MarkNoShow() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const [booking, setBooking] = useState(null); + const [attendees, setAttendees] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getBookingByUid(uid) + .then((bookingData) => { + setBooking(bookingData); + // API may return "absent" or "noShow" depending on endpoint version + const bookingAttendees: Attendee[] = []; + if (bookingData.attendees && Array.isArray(bookingData.attendees)) { + bookingData.attendees.forEach((att: any) => { + bookingAttendees.push({ + id: att.id, + email: att.email, + name: att.name || att.email, + noShow: att.absent === true || att.noShow === true, + }); + }); + } + setAttendees(bookingAttendees); + }) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + if (isLoading) { + return ( + <> + + + {/* iOS-only Stack.Header */} + {Platform.OS === "ios" && ( + + Mark No-Show + + )} + + + + + + ); + } + + return ( + <> + + + {/* iOS-only Stack.Header with native styling */} + {Platform.OS === "ios" && ( + + Mark No-Show + + )} + + { + setBooking(updatedBooking); + const updatedAttendees: Attendee[] = []; + if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) { + updatedBooking.attendees.forEach((att: any) => { + updatedAttendees.push({ + id: att.id, + email: att.email, + name: att.name || att.email, + noShow: att.absent === true || att.noShow === true, + }); + }); + } + setAttendees(updatedAttendees); + }} + /> + + ); +} diff --git a/companion/app/(tabs)/(bookings)/meeting-session-details.tsx b/companion/app/(tabs)/(bookings)/meeting-session-details.tsx new file mode 100644 index 0000000000..2c17d402ff --- /dev/null +++ b/companion/app/(tabs)/(bookings)/meeting-session-details.tsx @@ -0,0 +1,84 @@ +import MeetingSessionDetailsScreen from "../../../components/screens/MeetingSessionDetailsScreen"; +import { CalComAPIService, type Booking } from "../../../services/calcom"; +import type { ConferencingSession } from "../../../services/types/bookings.types"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import React, { useState, useEffect } from "react"; +import { Alert, ActivityIndicator, View, Platform } from "react-native"; + +export default function MeetingSessionDetails() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const [booking, setBooking] = useState(null); + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (uid) { + setIsLoading(true); + Promise.all([ + CalComAPIService.getBookingByUid(uid), + CalComAPIService.getConferencingSessions(uid).catch((error) => { + console.error("Failed to load sessions:", error); + return []; + }), + ]) + .then(([bookingData, sessionsData]) => { + setBooking(bookingData); + setSessions(sessionsData); + }) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + if (isLoading) { + return ( + <> + + + {/* iOS-only Stack.Header */} + {Platform.OS === "ios" && ( + + Session Details + + )} + + + + + + ); + } + + return ( + <> + + + {/* iOS-only Stack.Header with native styling */} + {Platform.OS === "ios" && ( + + Session Details + + )} + + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/reschedule.tsx b/companion/app/(tabs)/(bookings)/reschedule.tsx new file mode 100644 index 0000000000..280e17cfcb --- /dev/null +++ b/companion/app/(tabs)/(bookings)/reschedule.tsx @@ -0,0 +1,109 @@ +import { AppPressable } from "../../../components/AppPressable"; +import RescheduleScreen from "../../../components/screens/RescheduleScreen"; +import type { RescheduleScreenHandle } from "../../../components/screens/RescheduleScreen"; +import { CalComAPIService, type Booking } from "../../../services/calcom"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { Alert, ActivityIndicator, View, Text, Platform } from "react-native"; + +export default function Reschedule() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const [booking, setBooking] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const rescheduleScreenRef = useRef(null); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getBookingByUid(uid) + .then(setBooking) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const handleSave = useCallback(() => { + rescheduleScreenRef.current?.submit(); + }, []); + + const handleRescheduleSuccess = useCallback(() => { + router.back(); + }, [router]); + + const renderHeaderRight = useCallback( + () => ( + + Save + + ), + [handleSave, isSaving] + ); + + if (isLoading) { + return ( + <> + + + {Platform.OS === "ios" && ( + + Reschedule + + )} + + + + + + ); + } + + return ( + <> + + + {Platform.OS === "ios" && ( + + Reschedule + + + + Save + + + + )} + + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/view-recordings.tsx b/companion/app/(tabs)/(bookings)/view-recordings.tsx new file mode 100644 index 0000000000..9f52ac2963 --- /dev/null +++ b/companion/app/(tabs)/(bookings)/view-recordings.tsx @@ -0,0 +1,75 @@ +import ViewRecordingsScreen from "../../../components/screens/ViewRecordingsScreen"; +import { CalComAPIService } from "../../../services/calcom"; +import type { BookingRecording } from "../../../services/types/bookings.types"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import React, { useState, useEffect } from "react"; +import { Alert, ActivityIndicator, View, Platform } from "react-native"; + +export default function ViewRecordings() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const [recordings, setRecordings] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getRecordings(uid) + .then(setRecordings) + .catch((error) => { + console.error("Failed to load recordings:", error); + Alert.alert("Error", "Failed to load recordings"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + if (isLoading) { + return ( + <> + + + {/* iOS-only Stack.Header */} + {Platform.OS === "ios" && ( + + Recordings + + )} + + + + + + ); + } + + return ( + <> + + + {/* iOS-only Stack.Header with native styling */} + {Platform.OS === "ios" && ( + + Recordings + + )} + + + + ); +} diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx index d4a8bf653c..c7d52939bd 100644 --- a/companion/app/(tabs)/(event-types)/index.tsx +++ b/companion/app/(tabs)/(event-types)/index.tsx @@ -22,7 +22,7 @@ import { Ionicons } from "@expo/vector-icons"; import * as Clipboard from "expo-clipboard"; import { isLiquidGlassAvailable } from "expo-glass-effect"; import { Stack, useRouter } from "expo-router"; -import React, { useState, useMemo, Activity } from "react"; +import React, { useState, useMemo } from "react"; import { View, Text, @@ -397,9 +397,7 @@ export default function EventTypes() { if (loading) { return ( - -
- + {Platform.OS === "web" &&
} @@ -410,9 +408,7 @@ export default function EventTypes() { if (error) { return ( - -
- + {Platform.OS === "web" &&
} @@ -430,9 +426,7 @@ export default function EventTypes() { if (eventTypes.length === 0) { return ( - -
- + {Platform.OS === "web" &&
} - -
- - - - - New - - - + {Platform.OS === "web" && ( + <> +
+ + + + + New + + + + )} - -
- - - - - New - - - + {(Platform.OS === "web" || Platform.OS === "android") && ( + <> +
+ + + + + New + + + + )} ( router.push(item.href!) : item.onPress} + onPress={item.onPress} className={`flex-row items-center justify-between bg-white px-5 py-5 active:bg-[#F8F9FA] ${ index < menuItems.length - 1 ? "border-b border-[#E5E5EA]" : "" }`} diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx index b626401db0..702da16edd 100644 --- a/companion/app/event-type-detail.tsx +++ b/companion/app/event-type-detail.tsx @@ -560,11 +560,13 @@ export default function EventTypeDetail() { } if (eventType.metadata) { - if (eventType.metadata.calendarEventName) { - setCalendarEventName(eventType.metadata.calendarEventName); + const calendarEventNameValue = eventType.metadata.calendarEventName; + if (typeof calendarEventNameValue === "string") { + setCalendarEventName(calendarEventNameValue); } - if (eventType.metadata.addToCalendarEmail) { - setAddToCalendarEmail(eventType.metadata.addToCalendarEmail); + const addToCalendarEmailValue = eventType.metadata.addToCalendarEmail; + if (typeof addToCalendarEmailValue === "string") { + setAddToCalendarEmail(addToCalendarEmailValue); } } diff --git a/companion/app/oauth/callback.tsx b/companion/app/oauth/callback.tsx index ea5207021d..7fe90790a6 100644 --- a/companion/app/oauth/callback.tsx +++ b/companion/app/oauth/callback.tsx @@ -11,7 +11,7 @@ export default function OAuthCallback() { useEffect(() => { if (Platform.OS === "android") { if (auth.userInfo) { - router.replace("/(tabs)"); + router.replace("/"); } } @@ -45,7 +45,7 @@ export default function OAuthCallback() { window.localStorage.setItem(`oauth_callback_error_${state}`, errorMessage); window.localStorage.setItem(`oauth_callback_error_code_${state}`, error); } - router.replace("/(tabs)"); + router.replace("/"); } } return; @@ -71,7 +71,7 @@ export default function OAuthCallback() { window.close(); } else { // Redirect to main app - router.replace("/(tabs)"); + router.replace("/"); } } } else { @@ -89,7 +89,7 @@ export default function OAuthCallback() { ); window.close(); } else { - router.replace("/(tabs)"); + router.replace("/"); } } } diff --git a/companion/components/BookingActionsModal.ios.tsx b/companion/components/BookingActionsModal.ios.tsx new file mode 100644 index 0000000000..bc95a2e712 --- /dev/null +++ b/companion/components/BookingActionsModal.ios.tsx @@ -0,0 +1,331 @@ +/** + * BookingActionsModal Component - iOS Implementation + * + * iOS-specific modal for booking actions with Glass UI styling. + * This provides a native iOS action sheet experience with Glass UI design. + */ +import type { Booking } from "../services/calcom"; +import type { BookingActionsResult } from "../utils/booking-actions"; +import { FullScreenModal } from "./FullScreenModal"; +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { View, Text, TouchableOpacity, ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export interface BookingActionsModalProps { + visible: boolean; + onClose: () => void; + booking: Booking | null; + actions: BookingActionsResult; + onReschedule: () => void; + onRequestReschedule?: () => void; + onEditLocation: () => void; + onAddGuests: () => void; + onViewRecordings: () => void; + onMeetingSessionDetails: () => void; + onMarkNoShow: () => void; + onReportBooking: () => void; + onCancelBooking: () => void; +} + +// Icon mapping for actions +const ACTION_ICONS: Record = { + reschedule: "calendar", + rescheduleRequest: "send", + changeLocation: "location", + addGuests: "person-add", + viewRecordings: "videocam", + meetingSessionDetails: "information-circle", + markNoShow: "eye-off", + report: "flag", + cancel: "close-circle", +}; + +interface ActionButtonProps { + icon: keyof typeof Ionicons.glyphMap; + label: string; + onPress: () => void; + visible: boolean; + enabled: boolean; + isDanger?: boolean; + isLast?: boolean; +} + +function ActionButton({ + icon, + label, + onPress, + visible, + enabled, + isDanger = false, + isLast = false, +}: ActionButtonProps) { + if (!visible) return null; + + const iconColor = !enabled ? "#C7C7CC" : isDanger ? "#FF3B30" : "#007AFF"; + const textColor = !enabled ? "#C7C7CC" : isDanger ? "#FF3B30" : "#000"; + + return ( + { + if (!enabled) return; + onPress(); + }} + disabled={!enabled} + className={`flex-row items-center px-4 py-3.5 active:bg-[#F8F9FA] ${ + !isLast ? "border-b border-gray-100" : "" + }`} + activeOpacity={0.7} + > + + + + + {label} + + {!enabled && ( + + Unavailable + + )} + + ); +} + +interface SectionHeaderProps { + title: string; +} + +function SectionHeader({ title }: SectionHeaderProps) { + return ( + + {title} + + ); +} + +export function BookingActionsModal({ + visible, + onClose, + booking, + actions, + onReschedule, + onRequestReschedule, + onEditLocation, + onAddGuests, + onViewRecordings, + onMeetingSessionDetails, + onMarkNoShow, + onReportBooking, + onCancelBooking, +}: BookingActionsModalProps) { + const insets = useSafeAreaInsets(); + + if (!booking) return null; + + const handleAction = (action: () => void) => { + onClose(); + action(); + }; + + // Check if any edit event actions are visible + const hasEditEventActions = + actions.reschedule.visible || + actions.rescheduleRequest.visible || + actions.changeLocation.visible || + actions.addGuests.visible; + + // Check if any after event actions are visible + const hasAfterEventActions = + actions.viewRecordings.visible || + actions.meetingSessionDetails.visible || + actions.markNoShow.visible; + + // Define edit event actions + const editEventActions = [ + { + key: "reschedule", + icon: ACTION_ICONS.reschedule, + label: "Reschedule Booking", + onPress: () => handleAction(onReschedule), + visible: actions.reschedule.visible, + enabled: actions.reschedule.enabled, + }, + ...(onRequestReschedule + ? [ + { + key: "rescheduleRequest", + icon: ACTION_ICONS.rescheduleRequest, + label: "Request Reschedule", + onPress: () => handleAction(onRequestReschedule), + visible: actions.rescheduleRequest.visible, + enabled: actions.rescheduleRequest.enabled, + }, + ] + : []), + { + key: "changeLocation", + icon: ACTION_ICONS.changeLocation, + label: "Edit Location", + onPress: () => handleAction(onEditLocation), + visible: actions.changeLocation.visible, + enabled: actions.changeLocation.enabled, + }, + { + key: "addGuests", + icon: ACTION_ICONS.addGuests, + label: "Add Guests", + onPress: () => handleAction(onAddGuests), + visible: actions.addGuests.visible, + enabled: actions.addGuests.enabled, + }, + ].filter((action) => action.visible); + + // Define after event actions + const afterEventActions = [ + { + key: "viewRecordings", + icon: ACTION_ICONS.viewRecordings, + label: "View Recordings", + onPress: () => handleAction(onViewRecordings), + visible: actions.viewRecordings.visible, + enabled: actions.viewRecordings.enabled, + }, + { + key: "meetingSessionDetails", + icon: ACTION_ICONS.meetingSessionDetails, + label: "Meeting Session Details", + onPress: () => handleAction(onMeetingSessionDetails), + visible: actions.meetingSessionDetails.visible, + enabled: actions.meetingSessionDetails.enabled, + }, + { + key: "markNoShow", + icon: ACTION_ICONS.markNoShow, + label: "Mark as No-Show", + onPress: () => handleAction(onMarkNoShow), + visible: actions.markNoShow.visible, + enabled: actions.markNoShow.enabled, + }, + ].filter((action) => action.visible); + + // Define danger zone actions + const dangerZoneActions = [ + { + key: "report", + icon: ACTION_ICONS.report, + label: "Report Booking", + onPress: () => handleAction(onReportBooking), + visible: true, + enabled: true, + isDanger: true, + }, + { + key: "cancel", + icon: ACTION_ICONS.cancel, + label: "Cancel Event", + onPress: () => handleAction(onCancelBooking), + visible: actions.cancel.visible, + enabled: actions.cancel.enabled, + isDanger: true, + }, + ].filter((action) => action.visible); + + return ( + + + e.stopPropagation()} + style={{ paddingBottom: insets.bottom || 16 }} + > + {/* Actions Card */} + + {/* Booking Title Header */} + + + {booking.title} + + + + + {/* Edit Event Section */} + {hasEditEventActions && ( + <> + + {editEventActions.map((action, index) => ( + + ))} + + )} + + {/* After Event Section */} + {hasAfterEventActions && ( + <> + + {afterEventActions.map((action, index) => ( + + ))} + + )} + + {/* Danger Zone Section */} + {dangerZoneActions.length > 0 && ( + <> + + {dangerZoneActions.map((action, index) => ( + + ))} + + )} + + + + {/* Cancel Button */} + + + Cancel + + + + + + ); +} diff --git a/companion/components/BookingActionsModal.tsx b/companion/components/BookingActionsModal.tsx index 57a460d615..9374b42358 100644 --- a/companion/components/BookingActionsModal.tsx +++ b/companion/components/BookingActionsModal.tsx @@ -1,27 +1,29 @@ /** - * BookingActionsModal Component + * BookingActionsModal Component - Android/Web Implementation * * A reusable modal component for booking actions that can be used in both * the bookings list screen and the booking detail screen. + * + * This component uses the centralized action gating utility for consistent + * action visibility and enabled state across the app. + * + * Note: iOS uses BookingActionsModal.ios.tsx with native Glass UI styling. */ - +import type { Booking } from "../services/calcom"; +import type { BookingActionsResult } from "../utils/booking-actions"; +import { FullScreenModal } from "./FullScreenModal"; import { Ionicons } from "@expo/vector-icons"; import React from "react"; -import { View, Text, TouchableOpacity, Alert } from "react-native"; - -import { FullScreenModal } from "./FullScreenModal"; -import type { Booking } from "../services/calcom"; +import { View, Text, TouchableOpacity, ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; export interface BookingActionsModalProps { visible: boolean; onClose: () => void; booking: Booking | null; - hasLocationUrl?: boolean; - isUpcoming?: boolean; // When true, disables "after event" actions (View Recordings, Session Details, Mark No-Show) - isPast?: boolean; // When true, disables "edit event" actions (Reschedule, Edit Location, Add Guests) and Cancel Booking - isCancelled?: boolean; // When true, only Report Booking and Mark as No-Show are enabled - isUnconfirmed?: boolean; // When true, disables Reschedule, Edit Location, Add Guests + actions: BookingActionsResult; onReschedule: () => void; + onRequestReschedule?: () => void; onEditLocation: () => void; onAddGuests: () => void; onViewRecordings: () => void; @@ -31,26 +33,91 @@ export interface BookingActionsModalProps { onCancelBooking: () => void; } -// Style constants for easy customization -const ICON_SIZE = 16; -const ICON_COLOR = "#6B7280"; -const ICON_COLOR_DANGER = "#800000"; // Maroon -const DISABLED_ICON_COLOR = "#D1D5DB"; -const TEXT_CLASS = "text-lg"; -const TEXT_COLOR_CLASS = "text-gray-900"; -const TEXT_COLOR_DANGER_CLASS = "text-[#800000]"; // Maroon -const DISABLED_TEXT_COLOR_CLASS = "text-gray-300"; +// Icon mapping for actions +const ACTION_ICONS: Record = { + reschedule: "calendar-outline", + rescheduleRequest: "send-outline", + changeLocation: "location-outline", + addGuests: "person-add-outline", + viewRecordings: "videocam-outline", + meetingSessionDetails: "information-circle-outline", + markNoShow: "eye-off-outline", + report: "flag-outline", + cancel: "close-circle-outline", +}; + +interface ActionButtonProps { + icon: keyof typeof Ionicons.glyphMap; + label: string; + onPress: () => void; + visible: boolean; + enabled: boolean; + isDanger?: boolean; + isLast?: boolean; +} + +function ActionButton({ + icon, + label, + onPress, + visible, + enabled, + isDanger = false, + isLast = false, +}: ActionButtonProps) { + if (!visible) return null; + + const iconColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#6B7280"; + const textColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#111827"; + + return ( + { + if (!enabled) return; + onPress(); + }} + disabled={!enabled} + className={`flex-row items-center px-4 py-3 active:bg-gray-50 ${ + !isLast ? "border-b border-gray-100" : "" + }`} + activeOpacity={0.7} + > + + + + + {label} + + {!enabled && ( + + Unavailable + + )} + + ); +} + +interface SectionHeaderProps { + title: string; +} + +function SectionHeader({ title }: SectionHeaderProps) { + return ( + + + {title} + + + ); +} export function BookingActionsModal({ visible, onClose, booking, - hasLocationUrl = false, - isUpcoming = false, - isPast = false, - isCancelled = false, - isUnconfirmed = false, + actions, onReschedule, + onRequestReschedule, onEditLocation, onAddGuests, onViewRecordings, @@ -59,228 +126,207 @@ export function BookingActionsModal({ onReportBooking, onCancelBooking, }: BookingActionsModalProps) { + const insets = useSafeAreaInsets(); + if (!booking) return null; - // For cancelled bookings, only Report Booking and Mark as No-Show are enabled - // "After event" actions (except Mark as No-Show) are disabled for upcoming, cancelled, or unconfirmed bookings - const afterEventActionsDisabled = isUpcoming || isCancelled || isUnconfirmed; - // "Edit event" actions (Reschedule, Edit Location, Add Guests) are disabled for past, cancelled, or unconfirmed bookings - const editEventActionsDisabled = isPast || isCancelled || isUnconfirmed; - // Cancel booking is disabled for past or cancelled bookings (but NOT for unconfirmed - user can still cancel/decline) - const cancelBookingDisabled = isPast || isCancelled; + const handleAction = (action: () => void) => { + onClose(); + action(); + }; + + // Check if any edit event actions are visible + const hasEditEventActions = + actions.reschedule.visible || + actions.rescheduleRequest.visible || + actions.changeLocation.visible || + actions.addGuests.visible; + + // Check if any after event actions are visible + const hasAfterEventActions = + actions.viewRecordings.visible || + actions.meetingSessionDetails.visible || + actions.markNoShow.visible; + + // Define edit event actions + const editEventActions = [ + { + key: "reschedule", + icon: ACTION_ICONS.reschedule, + label: "Reschedule Booking", + onPress: () => handleAction(onReschedule), + visible: actions.reschedule.visible, + enabled: actions.reschedule.enabled, + }, + ...(onRequestReschedule + ? [ + { + key: "rescheduleRequest", + icon: ACTION_ICONS.rescheduleRequest, + label: "Request Reschedule", + onPress: () => handleAction(onRequestReschedule), + visible: actions.rescheduleRequest.visible, + enabled: actions.rescheduleRequest.enabled, + }, + ] + : []), + { + key: "changeLocation", + icon: ACTION_ICONS.changeLocation, + label: "Edit Location", + onPress: () => handleAction(onEditLocation), + visible: actions.changeLocation.visible, + enabled: actions.changeLocation.enabled, + }, + { + key: "addGuests", + icon: ACTION_ICONS.addGuests, + label: "Add Guests", + onPress: () => handleAction(onAddGuests), + visible: actions.addGuests.visible, + enabled: actions.addGuests.enabled, + }, + ].filter((action) => action.visible); + + // Define after event actions + const afterEventActions = [ + { + key: "viewRecordings", + icon: ACTION_ICONS.viewRecordings, + label: "View Recordings", + onPress: () => handleAction(onViewRecordings), + visible: actions.viewRecordings.visible, + enabled: actions.viewRecordings.enabled, + }, + { + key: "meetingSessionDetails", + icon: ACTION_ICONS.meetingSessionDetails, + label: "Meeting Session Details", + onPress: () => handleAction(onMeetingSessionDetails), + visible: actions.meetingSessionDetails.visible, + enabled: actions.meetingSessionDetails.enabled, + }, + { + key: "markNoShow", + icon: ACTION_ICONS.markNoShow, + label: "Mark as No-Show", + onPress: () => handleAction(onMarkNoShow), + visible: actions.markNoShow.visible, + enabled: actions.markNoShow.enabled, + }, + ].filter((action) => action.visible); + + // Define danger zone actions + const dangerZoneActions = [ + { + key: "report", + icon: ACTION_ICONS.report, + label: "Report Booking", + onPress: () => handleAction(onReportBooking), + visible: true, + enabled: true, + isDanger: true, + }, + { + key: "cancel", + icon: ACTION_ICONS.cancel, + label: "Cancel Event", + onPress: () => handleAction(onCancelBooking), + visible: actions.cancel.visible, + enabled: actions.cancel.enabled, + isDanger: true, + }, + ].filter((action) => action.visible); return ( e.stopPropagation()} > - {/* Actions List */} - - {/* Edit event label */} - - Edit event + {/* Actions Card */} + + {/* Booking Title Header */} + + + {booking.title} + - {/* Reschedule Booking */} - { - if (editEventActionsDisabled) return; - onClose(); - onReschedule(); - }} - disabled={editEventActionsDisabled} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - Reschedule Booking - - + + {/* Edit Event Section */} + {hasEditEventActions && ( + <> + + {editEventActions.map((action, index) => ( + + ))} + + )} - {/* Edit Location */} - { - if (editEventActionsDisabled) return; - onClose(); - onEditLocation(); - }} - disabled={editEventActionsDisabled} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - Edit Location - - + {/* After Event Section */} + {hasAfterEventActions && ( + <> + + {afterEventActions.map((action, index) => ( + + ))} + + )} - {/* Add Guests */} - { - if (editEventActionsDisabled) return; - onClose(); - onAddGuests(); - }} - disabled={editEventActionsDisabled} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - Add Guests - - + {/* Danger Zone Section */} + {dangerZoneActions.length > 0 && ( + <> + + {dangerZoneActions.map((action, index) => ( + + ))} + + )} + + - {/* Separator */} - - - {/* After event label */} - - After event + {/* Cancel Button */} + + + Cancel - - {/* View Recordings */} - {hasLocationUrl ? ( - { - if (afterEventActionsDisabled) return; - onClose(); - onViewRecordings(); - }} - disabled={afterEventActionsDisabled} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - View Recordings - - - ) : null} - - {/* Meeting Session Details */} - {hasLocationUrl ? ( - { - if (afterEventActionsDisabled) return; - onClose(); - onMeetingSessionDetails(); - }} - disabled={afterEventActionsDisabled} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - Meeting Session Details - - - ) : null} - - {/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */} - { - if (isUpcoming || isUnconfirmed) return; - onClose(); - onMarkNoShow(); - }} - disabled={isUpcoming || isUnconfirmed} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - Mark as No-Show - - - - {/* Separator */} - - - {/* Report Booking */} - { - onClose(); - onReportBooking(); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - Report Booking - - - - {/* Separator */} - - - {/* Cancel Booking */} - { - if (cancelBookingDisabled) return; - onClose(); - onCancelBooking(); - }} - disabled={cancelBookingDisabled} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - Cancel Event - - - - - {/* Cancel button */} - - - Cancel - - + diff --git a/companion/components/GlassModalHeader.tsx b/companion/components/GlassModalHeader.tsx new file mode 100644 index 0000000000..d88e976e1e --- /dev/null +++ b/companion/components/GlassModalHeader.tsx @@ -0,0 +1,82 @@ +import { AppPressable } from "./AppPressable"; +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { View, Text, ActivityIndicator } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +interface GlassModalHeaderProps { + title: string; + onClose: () => void; + onAction?: () => void; + actionLabel?: string; + actionDisabled?: boolean; + actionLoading?: boolean; + /** Use icon button style (like three-dot menu) instead of text */ + actionIcon?: keyof typeof Ionicons.glyphMap; +} + +export function GlassModalHeader({ + title, + onClose, + onAction, + actionLabel = "Done", + actionDisabled = false, + actionLoading = false, + actionIcon, +}: GlassModalHeaderProps) { + const insets = useSafeAreaInsets(); + + const isDisabled = actionDisabled || actionLoading; + + return ( + + + {/* Back Button - matching native iOS style */} + + + + + {/* Title - centered */} + + {title} + + + {/* Action Button - matching senior's Stack.Header.Menu style */} + {onAction ? ( + + {actionLoading ? ( + + ) : actionIcon ? ( + // Icon button style (like the three-dot menu) + + ) : ( + // Text button style (like "Save", "Done") + + {actionLabel} + + )} + + ) : ( + + )} + + + ); +} diff --git a/companion/components/booking-list-item/BookingListItem.ios.tsx b/companion/components/booking-list-item/BookingListItem.ios.tsx index 09eca44244..a5d89e5f81 100644 --- a/companion/components/booking-list-item/BookingListItem.ios.tsx +++ b/companion/components/booking-list-item/BookingListItem.ios.tsx @@ -10,6 +10,7 @@ import { SvgImage } from "../SvgImage"; import { getMeetingInfo } from "../../utils/meetings-utils"; import { formatTime, formatDate, getHostAndAttendeesDisplay } from "../../utils/bookings-utils"; import { showErrorAlert } from "../../utils/alerts"; +import { getBookingActions } from "../../utils/booking-actions"; export const BookingListItem: React.FC = ({ booking, @@ -41,6 +42,22 @@ export const BookingListItem: React.FC = ({ const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail); const meetingInfo = getMeetingInfo(booking.location); + // Check if any attendee is marked as no-show + const hasNoShowAttendee = booking.attendees?.some( + (att: any) => att.noShow === true || att.absent === true + ); + + // Use centralized action gating for consistency + const actions = React.useMemo(() => { + return getBookingActions({ + booking, + eventType: undefined, + currentUserId: undefined, // Not available in this context + currentUserEmail: userEmail, + isOnline: true, // Assume online + }); + }, [booking, userEmail]); + // Define context menu actions based on booking state type ContextMenuAction = { label: string; @@ -78,7 +95,8 @@ export const BookingListItem: React.FC = ({ icon: "video", onPress: () => onViewRecordings?.(booking), role: "default", - visible: hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onViewRecordings, + visible: + actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings, }, { label: "Meeting Session Details", @@ -86,14 +104,16 @@ export const BookingListItem: React.FC = ({ onPress: () => onMeetingSessionDetails?.(booking), role: "default", visible: - hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onMeetingSessionDetails, + actions.meetingSessionDetails.visible && + actions.meetingSessionDetails.enabled && + !!onMeetingSessionDetails, }, { label: "Mark as No-Show", icon: "eye.slash", onPress: () => onMarkNoShow?.(booking), role: "default", - visible: !isUpcoming && !isPending && !!onMarkNoShow, + visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow, }, // Other Actions { @@ -156,7 +176,15 @@ export const BookingListItem: React.FC = ({ ) : null} {/* Host and Attendees */} {hostAndAttendeesDisplay ? ( - {hostAndAttendeesDisplay} + + {hostAndAttendeesDisplay} + {hasNoShowAttendee && ( + + + No-show + + )} + ) : null} {/* Meeting Link */} {meetingInfo ? ( diff --git a/companion/components/booking-list-item/BookingListItem.tsx b/companion/components/booking-list-item/BookingListItem.tsx index de2de5b568..aee39ea08e 100644 --- a/companion/components/booking-list-item/BookingListItem.tsx +++ b/companion/components/booking-list-item/BookingListItem.tsx @@ -29,6 +29,11 @@ export const BookingListItem: React.FC = ({ const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail); const meetingInfo = getMeetingInfo(booking.location); + // Check if any attendee is marked as no-show + const hasNoShowAttendee = booking.attendees?.some( + (att: any) => att.noShow === true || att.absent === true + ); + return ( = ({ ) : null} {/* Host and Attendees */} {hostAndAttendeesDisplay ? ( - {hostAndAttendeesDisplay} + + {hostAndAttendeesDisplay} + {hasNoShowAttendee && ( + + + No-show + + )} + ) : null} {/* Meeting Link */} {meetingInfo ? ( diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx index c937d2069b..1708284dc9 100644 --- a/companion/components/booking-list-screen/BookingListScreen.tsx +++ b/companion/components/booking-list-screen/BookingListScreen.tsx @@ -16,6 +16,7 @@ import { useDeclineBooking, useRescheduleBooking, useBookingActions, + useBookingActionModals, type BookingFilter, } from "../../hooks"; import { offlineAwareRefresh } from "../../utils/network"; @@ -102,23 +103,12 @@ export const BookingListScreen: React.FC = ({ // Booking actions hook const { - showRescheduleModal, - rescheduleBooking, - rescheduleDate, - setRescheduleDate, - rescheduleTime, - setRescheduleTime, - rescheduleReason, - setRescheduleReason, showRejectModal, rejectReason, setRejectReason, selectedBooking, setSelectedBooking, handleBookingPress, - handleRescheduleBooking, - handleSubmitReschedule, - handleCloseRescheduleModal, handleCancelBooking, handleInlineConfirm, handleOpenRejectModal, @@ -135,6 +125,75 @@ export const BookingListScreen: React.FC = ({ isRescheduling, }); + // Booking action modals hook + const { selectedBooking: actionModalBooking } = useBookingActionModals(); + + // Navigate to reschedule screen (same pattern as booking detail) + const handleNavigateToReschedule = React.useCallback( + (booking: Booking) => { + router.push({ + pathname: "/(tabs)/(bookings)/reschedule", + params: { uid: booking.uid }, + }); + }, + [router] + ); + + // Navigate to edit location screen (same pattern as booking detail) + const handleNavigateToEditLocation = React.useCallback( + (booking: Booking) => { + router.push({ + pathname: "/(tabs)/(bookings)/edit-location", + params: { uid: booking.uid }, + }); + }, + [router] + ); + + // Navigate to add guests screen (same pattern as booking detail) + const handleNavigateToAddGuests = React.useCallback( + (booking: Booking) => { + router.push({ + pathname: "/(tabs)/(bookings)/add-guests", + params: { uid: booking.uid }, + }); + }, + [router] + ); + + // Navigate to mark no show screen (same pattern as booking detail) + const handleNavigateToMarkNoShow = React.useCallback( + (booking: Booking) => { + router.push({ + pathname: "/(tabs)/(bookings)/mark-no-show", + params: { uid: booking.uid }, + }); + }, + [router] + ); + + // Navigate to view recordings screen (same pattern as booking detail) + const handleNavigateToViewRecordings = React.useCallback( + (booking: Booking) => { + router.push({ + pathname: "/(tabs)/(bookings)/view-recordings", + params: { uid: booking.uid }, + }); + }, + [router] + ); + + // Navigate to meeting session details screen (same pattern as booking detail) + const handleNavigateToMeetingSessionDetails = React.useCallback( + (booking: Booking) => { + router.push({ + pathname: "/(tabs)/(bookings)/meeting-session-details", + params: { uid: booking.uid }, + }); + }, + [router] + ); + // Sort bookings based on active filter const bookings = useMemo(() => { if (!rawBookings || !Array.isArray(rawBookings)) return []; @@ -208,28 +267,15 @@ export const BookingListScreen: React.FC = ({ setSelectedBooking(booking); setShowBookingActionsModal(true); }} - // iOS context menu action handlers - onReschedule={handleRescheduleBooking} - 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"); - }} + // iOS context menu action handlers - now use screen navigation + onReschedule={handleNavigateToReschedule} + onEditLocation={handleNavigateToEditLocation} + onAddGuests={handleNavigateToAddGuests} + onViewRecordings={handleNavigateToViewRecordings} + onMeetingSessionDetails={handleNavigateToMeetingSessionDetails} + onMarkNoShow={handleNavigateToMarkNoShow} onReportBooking={() => { - Alert.alert("Report Booking", "Report booking functionality coming soon"); + Alert.alert("Report Booking", "Report booking functionality is not yet available"); }} onCancelBooking={handleCancelBooking} /> @@ -368,21 +414,16 @@ export const BookingListScreen: React.FC = ({ {/* Modals */} {}} + onRescheduleSubmit={async () => {}} showRejectModal={showRejectModal} rejectReason={rejectReason} isDeclining={isDeclining} onRejectClose={handleCloseRejectModal} + currentUserEmail={userInfo?.email} onRejectSubmit={handleSubmitReject} onRejectReasonChange={setRejectReason} showFilterModal={showFilterModal} @@ -395,12 +436,43 @@ export const BookingListScreen: React.FC = ({ selectedBooking={selectedBooking} onActionsClose={() => setShowBookingActionsModal(false)} onReschedule={() => { - if (selectedBooking) handleRescheduleBooking(selectedBooking); + // Navigate to reschedule screen instead of opening modal + if (selectedBooking) { + setShowBookingActionsModal(false); + handleNavigateToReschedule(selectedBooking); + } }} onCancel={() => { if (selectedBooking) handleCancelBooking(selectedBooking); }} + onEditLocation={(booking) => { + // Navigate to edit location screen instead of opening modal + setShowBookingActionsModal(false); + handleNavigateToEditLocation(booking); + }} + onAddGuests={(booking) => { + // Navigate to add guests screen instead of opening modal + setShowBookingActionsModal(false); + handleNavigateToAddGuests(booking); + }} + onViewRecordings={(booking) => { + // Navigate to view recordings screen instead of opening modal + setShowBookingActionsModal(false); + handleNavigateToViewRecordings(booking); + }} + onMeetingSessionDetails={(booking) => { + // Navigate to meeting session details screen instead of opening modal + setShowBookingActionsModal(false); + handleNavigateToMeetingSessionDetails(booking); + }} + onMarkNoShow={(booking) => { + // Navigate to mark no show screen instead of opening modal + setShowBookingActionsModal(false); + handleNavigateToMarkNoShow(booking); + }} /> + + {/* Action Modals */} ); }; diff --git a/companion/components/booking-modals/BookingModals.tsx b/companion/components/booking-modals/BookingModals.tsx index 9c66c67666..3991d0f40a 100644 --- a/companion/components/booking-modals/BookingModals.tsx +++ b/companion/components/booking-modals/BookingModals.tsx @@ -1,32 +1,39 @@ import { Ionicons } from "@expo/vector-icons"; -import React from "react"; +import React, { useMemo } from "react"; import { View, Text, TouchableOpacity, TextInput, ActivityIndicator, - ScrollView, Alert, + ScrollView, } from "react-native"; import type { Booking, EventType } from "../../services/calcom"; +import { getBookingActions, type BookingActionsResult } from "../../utils/booking-actions"; import { FullScreenModal } from "../FullScreenModal"; import { BookingActionsModal } from "../BookingActionsModal"; +// Empty actions result for when no booking is selected +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 }, +}; + interface BookingModalsProps { // Reschedule modal props showRescheduleModal: boolean; rescheduleBooking: Booking | null; - rescheduleDate: string; - rescheduleTime: string; - rescheduleReason: string; isRescheduling: boolean; onRescheduleClose: () => void; - onRescheduleSubmit: () => void; - onRescheduleDateChange: (date: string) => void; - onRescheduleTimeChange: (time: string) => void; - onRescheduleReasonChange: (reason: string) => void; + onRescheduleSubmit: (date: string, time: string, reason?: string) => Promise; // Reject modal props showRejectModal: boolean; @@ -50,20 +57,24 @@ interface BookingModalsProps { onActionsClose: () => void; onReschedule: () => void; onCancel: () => void; + + // User info for action gating (optional) + currentUserEmail?: string; + + // Action modal handlers (optional - if not provided, actions will be disabled) + onEditLocation?: (booking: Booking) => void; + onAddGuests?: (booking: Booking) => void; + onViewRecordings?: (booking: Booking) => void; + onMeetingSessionDetails?: (booking: Booking) => void; + onMarkNoShow?: (booking: Booking) => void; } export const BookingModals: React.FC = ({ showRescheduleModal, rescheduleBooking, - rescheduleDate, - rescheduleTime, - rescheduleReason, isRescheduling, onRescheduleClose, onRescheduleSubmit, - onRescheduleDateChange, - onRescheduleTimeChange, - onRescheduleReasonChange, showRejectModal, rejectReason, isDeclining, @@ -81,7 +92,25 @@ export const BookingModals: React.FC = ({ onActionsClose, onReschedule, onCancel, + currentUserEmail, + onEditLocation, + onAddGuests, + onViewRecordings, + onMeetingSessionDetails, + onMarkNoShow, }) => { + // Compute actions using centralized gating + const actions = useMemo(() => { + if (!selectedBooking) return EMPTY_ACTIONS; + return getBookingActions({ + booking: selectedBooking, + eventType: undefined, // EventType not available in this context + currentUserId: undefined, + currentUserEmail: currentUserEmail, + isOnline: true, // Assume online for now + }); + }, [selectedBooking, currentUserEmail]); + return ( <> {/* Filter Modal - Only rendered if props are provided (non-iOS) */} @@ -148,127 +177,39 @@ export const BookingModals: React.FC = ({ visible={showBookingActionsModal} onClose={onActionsClose} 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"} + actions={actions} onReschedule={onReschedule} onEditLocation={() => { - Alert.alert("Edit Location", "Edit location functionality coming soon"); + if (selectedBooking && onEditLocation) { + onEditLocation(selectedBooking); + } }} onAddGuests={() => { - Alert.alert("Add Guests", "Add guests functionality coming soon"); + if (selectedBooking && onAddGuests) { + onAddGuests(selectedBooking); + } }} onViewRecordings={() => { - Alert.alert("View Recordings", "View recordings functionality coming soon"); + if (selectedBooking && onViewRecordings) { + onViewRecordings(selectedBooking); + } }} onMeetingSessionDetails={() => { - Alert.alert( - "Meeting Session Details", - "Meeting session details functionality coming soon" - ); + if (selectedBooking && onMeetingSessionDetails) { + onMeetingSessionDetails(selectedBooking); + } }} onMarkNoShow={() => { - Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon"); + if (selectedBooking && onMarkNoShow) { + onMarkNoShow(selectedBooking); + } }} onReportBooking={() => { - Alert.alert("Report Booking", "Report booking functionality coming soon"); + Alert.alert("Report Booking", "Report booking functionality is not yet available"); }} onCancelBooking={onCancel} /> - {/* Reschedule Modal */} - - - {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 */} - - Cancel - - - ) : null} - - - {/* Reject Booking Modal */} void; + onSavingChange?: (isSaving: boolean) => void; +} + +// Handle type for parent component to call submit +export interface AddGuestsScreenHandle { + submit: () => void; +} + +function isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email.trim()); +} + +export const AddGuestsScreen = forwardRef( + function AddGuestsScreen({ booking, onSuccess, onSavingChange }, ref) { + const insets = useSafeAreaInsets(); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [guests, setGuests] = useState>([]); + const [isSaving, setIsSaving] = useState(false); + + // Notify parent of saving state changes + useEffect(() => { + onSavingChange?.(isSaving); + }, [isSaving, onSavingChange]); + + const handleAddGuest = useCallback(() => { + const trimmedEmail = email.trim(); + if (!trimmedEmail) { + Alert.alert("Error", "Please enter an email address"); + return; + } + + if (!isValidEmail(trimmedEmail)) { + Alert.alert("Error", "Please enter a valid email address"); + return; + } + + if (guests.some((g) => g.email.toLowerCase() === trimmedEmail.toLowerCase())) { + Alert.alert("Error", "This guest has already been added"); + return; + } + + setGuests([...guests, { email: trimmedEmail, name: name.trim() || undefined }]); + setEmail(""); + setName(""); + }, [email, name, guests]); + + const handleRemoveGuest = useCallback( + (index: number) => { + setGuests(guests.filter((_, i) => i !== index)); + }, + [guests] + ); + + const handleSubmit = useCallback(async () => { + if (!booking) return; + + if (guests.length === 0) { + Alert.alert("Error", "Please add at least one guest"); + return; + } + + setIsSaving(true); + try { + await CalComAPIService.addGuests(booking.uid, guests); + Alert.alert("Success", "Guests added successfully", [{ text: "OK", onPress: onSuccess }]); + } catch (error) { + Alert.alert("Error", error instanceof Error ? error.message : "Failed to add guests"); + } finally { + setIsSaving(false); + } + }, [booking, guests, onSuccess]); + + // Expose submit function to parent via ref (same pattern as senior's actionHandlersRef) + useImperativeHandle( + ref, + () => ({ + submit: handleSubmit, + }), + [handleSubmit] + ); + + if (!booking) { + return ( + + No booking data + + ); + } + + return ( + + + {/* Info note */} + + + + Guests will receive an email notification about this booking. + + + + {/* Form Card */} + + {/* Email input */} + + Email * + + + + {/* Name input */} + + Name (optional) + + + + + {/* Add button */} + + + Add Guest + + + {/* Guest list */} + {guests.length > 0 && ( + + + Guests to add ({guests.length}) + + + {guests.map((guest, index) => ( + + + + + + {guest.email} + {guest.name && ( + {guest.name} + )} + + handleRemoveGuest(index)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + disabled={isSaving} + > + + + + ))} + + + )} + + + ); + } +); + +// Default export for React Native compatibility +export default AddGuestsScreen; diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx index 3a4ec14afc..855b7963f0 100644 --- a/companion/components/screens/BookingDetailScreen.tsx +++ b/companion/components/screens/BookingDetailScreen.tsx @@ -1,29 +1,33 @@ import { CalComAPIService, Booking } from "../../services/calcom"; +import { useAuth } from "../../contexts/AuthContext"; +import { useBookingActionModals } from "../../hooks"; import { showErrorAlert } from "../../utils/alerts"; +import { getBookingActions, type BookingActionsResult } from "../../utils/booking-actions"; import { openInAppBrowser } from "../../utils/browser"; import { getDefaultLocationIconUrl, defaultLocations } from "../../utils/defaultLocations"; import { formatAppIdToDisplayName } from "../../utils/formatters"; import { getAppIconUrl } from "../../utils/getAppIconUrl"; -import { shadows } from "../../utils/shadows"; import { AppPressable } from "../AppPressable"; import { BookingActionsModal } from "../BookingActionsModal"; -import { FullScreenModal } from "../FullScreenModal"; import { SvgImage } from "../SvgImage"; import { Ionicons } from "@expo/vector-icons"; -import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { Stack, useRouter } from "expo-router"; -import React, { useState, useEffect } from "react"; -import { - View, - Text, - ScrollView, - Alert, - ActivityIndicator, - TextInput, - Platform, -} from "react-native"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { View, Text, ScrollView, Alert, ActivityIndicator, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +// Empty actions result for when no booking is loaded +const EMPTY_ACTIONS: BookingActionsResult = { + reschedule: { visible: false, enabled: false }, + rescheduleRequest: { visible: false, enabled: false }, + cancel: { visible: false, enabled: false }, + changeLocation: { visible: false, enabled: false }, + addGuests: { visible: false, enabled: false }, + viewRecordings: { visible: false, enabled: false }, + meetingSessionDetails: { visible: false, enabled: false }, + markNoShow: { visible: false, enabled: false }, +}; + // Format date: "Tuesday, November 25, 2025" const formatDateFull = (dateString: string): string => { if (!dateString) return ""; @@ -169,21 +173,164 @@ const getLocationProvider = (location: string | undefined, metadata?: Record void; + openEditLocationModal: () => void; + openAddGuestsModal: () => void; + openViewRecordingsModal: () => void; + openMeetingSessionDetailsModal: () => void; + openMarkNoShowModal: () => void; + handleCancelBooking: () => void; + }) => void; } -export function BookingDetailScreen({ uid }: BookingDetailScreenProps) { +export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps) { const router = useRouter(); const insets = useSafeAreaInsets(); + const { userInfo } = useAuth(); const [loading, setLoading] = useState(true); const [booking, setBooking] = useState(null); const [error, setError] = useState(null); const [showActionsModal, setShowActionsModal] = useState(false); - const [showRescheduleModal, setShowRescheduleModal] = useState(false); - const [rescheduleDate, setRescheduleDate] = useState(""); - const [rescheduleTime, setRescheduleTime] = useState(""); - const [rescheduleReason, setRescheduleReason] = useState(""); - const [rescheduling, setRescheduling] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + + // Compute actions using centralized gating + 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]); + + // Cancel booking handler (needs to be defined before useEffect that exposes it) + const performCancelBooking = useCallback( + async (reason: string) => { + if (!booking) return; + + setIsCancelling(true); + setShowActionsModal(false); + + try { + await CalComAPIService.cancelBooking(booking.uid, reason); + Alert.alert("Success", "Booking cancelled successfully", [ + { + text: "OK", + onPress: () => router.back(), + }, + ]); + } catch (error) { + console.error("Failed to cancel booking"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + console.debug("[BookingDetailScreen] cancelBooking failed", { message }); + } + showErrorAlert("Error", "Failed to cancel booking. Please try again."); + } finally { + setIsCancelling(false); + } + }, + [booking, router] + ); + + const handleCancelBooking = useCallback(() => { + if (!booking) return; + + Alert.alert("Cancel Booking", `Are you sure you want to cancel "${booking.title}"?`, [ + { text: "No", style: "cancel" }, + { + text: "Yes, Cancel", + style: "destructive", + onPress: () => { + // Prompt for cancellation reason (iOS only supports Alert.prompt) + if (Platform.OS === "ios") { + Alert.prompt( + "Cancellation Reason", + "Please provide a reason for cancelling this booking:", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Cancel Booking", + style: "destructive", + onPress: (reason) => { + performCancelBooking(reason?.trim() || "Cancelled by host"); + }, + }, + ], + "plain-text", + "", + "default" + ); + } else { + // For Android, just cancel with default reason + performCancelBooking("Cancelled by host"); + } + }, + }, + ]); + }, [booking, performCancelBooking]); + + // Navigate to reschedule screen (same pattern as senior's - navigate to screen in same folder) + const openRescheduleModal = useCallback(() => { + if (!booking) return; + router.push({ + pathname: "/(tabs)/(bookings)/reschedule", + params: { uid: booking.uid }, + }); + }, [booking, router]); + + // Navigate to edit location screen (same pattern as senior's - navigate to screen in same folder) + const openEditLocationModal = useCallback(() => { + if (!booking) return; + router.push({ + pathname: "/(tabs)/(bookings)/edit-location", + params: { uid: booking.uid }, + }); + }, [booking, router]); + + // Navigate to add guests screen (same pattern as senior's - navigate to screen in same folder) + const openAddGuestsModal = useCallback(() => { + if (!booking) return; + router.push({ + pathname: "/(tabs)/(bookings)/add-guests", + params: { uid: booking.uid }, + }); + }, [booking, router]); + + // Navigate to mark no show screen (same pattern as senior's - navigate to screen in same folder) + const openMarkNoShowModal = useCallback(() => { + if (!booking) return; + router.push({ + pathname: "/(tabs)/(bookings)/mark-no-show", + params: { uid: booking.uid }, + }); + }, [booking, router]); + + // Navigate to view recordings screen (same pattern as senior's - navigate to screen in same folder) + const openViewRecordingsModal = useCallback(() => { + if (!booking) return; + router.push({ + pathname: "/(tabs)/(bookings)/view-recordings", + params: { uid: booking.uid }, + }); + }, [booking, router]); + + // Navigate to meeting session details screen (same pattern as senior's - navigate to screen in same folder) + const openMeetingSessionDetailsModal = useCallback(() => { + if (!booking) return; + router.push({ + pathname: "/(tabs)/(bookings)/meeting-session-details", + params: { uid: booking.uid }, + }); + }, [booking, router]); useEffect(() => { if (uid) { @@ -191,6 +338,31 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) { } }, [uid]); + // Expose action handlers to parent component (for iOS header menu) + useEffect(() => { + if (booking && onActionsReady) { + onActionsReady({ + openRescheduleModal, + openEditLocationModal, + openAddGuestsModal, + openViewRecordingsModal, + openMeetingSessionDetailsModal, + openMarkNoShowModal, + handleCancelBooking, + }); + } + }, [ + booking, + onActionsReady, + openRescheduleModal, + openEditLocationModal, + openAddGuestsModal, + openViewRecordingsModal, + openMeetingSessionDetailsModal, + handleCancelBooking, + openMarkNoShowModal, + ]); + const fetchBooking = async () => { try { setLoading(true); @@ -236,81 +408,6 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) { } }; - const openRescheduleModal = () => { - if (!booking) return; - - // Pre-fill with the current booking date/time (using local timezone consistently) - const currentDate = new Date(booking.startTime); - const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart( - 2, - "0" - )}-${String(currentDate.getDate()).padStart(2, "0")}`; - const timeStr = `${String(currentDate.getHours()).padStart(2, "0")}:${String( - currentDate.getMinutes() - ).padStart(2, "0")}`; - - setRescheduleDate(dateStr); - setRescheduleTime(timeStr); - setRescheduleReason(""); - setShowRescheduleModal(true); - }; - - const handleReschedule = async () => { - if (!booking || !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(); - - setRescheduling(true); - try { - await CalComAPIService.rescheduleBooking(booking.uid, { - start: startUtc, - reschedulingReason: rescheduleReason.trim() || undefined, - }); - - Alert.alert("Success", "Booking rescheduled successfully"); - setShowRescheduleModal(false); - setRescheduleDate(""); - setRescheduleTime(""); - setRescheduleReason(""); - - // Refresh booking data - await fetchBooking(); - } catch (error) { - console.error("Failed to reschedule booking"); - if (__DEV__) { - const message = error instanceof Error ? error.message : String(error); - const stack = error instanceof Error ? error.stack : undefined; - console.debug("[BookingDetailScreen] rescheduleBooking failed", { message, stack }); - } - showErrorAlert("Error", "Failed to reschedule booking. Please try again."); - } finally { - setRescheduling(false); - } - }; - if (loading) { return ( @@ -422,21 +519,45 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) { ) : null} {booking.attendees && booking.attendees.length > 0 ? ( - {booking.attendees.map((attendee, index) => ( - 0 ? "mt-4" : ""}`}> - - - {getInitials(attendee.name)} - + {booking.attendees.map((attendee, index) => { + const isNoShow = + (attendee as any).noShow === true || (attendee as any).absent === true; + return ( + 0 ? "mt-4" : ""}`}> + + + {getInitials(attendee.name)} + + + + + + {attendee.name} + + {isNoShow && ( + + + + No-show + + + )} + + + {attendee.email} + + - - - {attendee.name} - - {attendee.email} - - - ))} + ); + })} ) : null} @@ -522,170 +643,30 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) { visible={showActionsModal} onClose={() => setShowActionsModal(false)} booking={booking} - hasLocationUrl={!!locationProvider?.url} - isUpcoming={booking ? new Date(booking.startTime) > new Date() : false} - isPast={booking ? new Date(booking.startTime) <= new Date() : false} - isCancelled={booking?.status?.toUpperCase() === "CANCELLED"} - isUnconfirmed={booking?.status?.toUpperCase() === "PENDING"} + actions={actions} onReschedule={openRescheduleModal} - 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"); - }} + onEditLocation={openEditLocationModal} + onAddGuests={openAddGuestsModal} + onViewRecordings={openViewRecordingsModal} + onMeetingSessionDetails={openMeetingSessionDetailsModal} + onMarkNoShow={openMarkNoShowModal} onReportBooking={() => { - Alert.alert("Report Booking", "Report booking functionality coming soon"); - }} - onCancelBooking={() => { - Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [ - { text: "No", style: "cancel" }, - { - text: "Yes, Cancel", - style: "destructive", - onPress: () => { - // TODO: Implement cancel booking - if (__DEV__) - console.debug("[BookingDetailScreen] cancel booking (not implemented yet)"); - }, - }, - ]); + Alert.alert("Report Booking", "Report booking functionality is not yet available"); }} + onCancelBooking={handleCancelBooking} /> - {/* Reschedule Modal */} - setShowRescheduleModal(false)} - > - setShowRescheduleModal(false)} - > - e.stopPropagation()} - style={shadows.xl()} - > - {/* Header */} - - - - - - - - Reschedule Booking - - - Select a new date and time for this booking. - - - - - - {/* Content */} - - {/* Date Input */} - - - New Date - (YYYY-MM-DD) - - - - - {/* Time Input */} - - - New Time - (HH:MM, 24-hour) - - - - - {/* Reason Input */} - - - Reason - (Optional) - - - - - - {/* Footer */} - - - { - setShowRescheduleModal(false); - setRescheduleDate(""); - setRescheduleTime(""); - setRescheduleReason(""); - }} - disabled={rescheduling} - > - Cancel - - - {rescheduling ? ( - - ) : ( - Reschedule - )} - - - - - - + {/* Cancelling overlay */} + {isCancelling && ( + + + + + Cancelling booking... + + + + )} ); diff --git a/companion/components/screens/EditLocationScreen.ios.tsx b/companion/components/screens/EditLocationScreen.ios.tsx new file mode 100644 index 0000000000..9ea974ddb8 --- /dev/null +++ b/companion/components/screens/EditLocationScreen.ios.tsx @@ -0,0 +1,303 @@ +/** + * EditLocationScreen Component (iOS) + * + * iOS-specific implementation with native context menu for location type selection. + * Uses @expo/ui/swift-ui ContextMenu for the glass UI feel. + */ +import type { Booking } from "../../services/calcom"; +import { CalComAPIService } from "../../services/calcom"; +import { Ionicons } from "@expo/vector-icons"; +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 React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from "react"; +import { View, Text, TextInput, Alert, ScrollView, KeyboardAvoidingView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +// Location types configuration +export const LOCATION_TYPES = { + link: { + id: "link", + label: "Meeting Link", + description: "Video call URL or web link", + iconName: "link" as const, + sfSymbol: "link" as const, + placeholder: "https://meet.example.com/your-meeting", + keyboardType: "url" as const, + inputType: "single" as const, + }, + phone: { + id: "phone", + label: "Phone Call", + description: "Phone number for the meeting", + iconName: "call" as const, + sfSymbol: "phone" as const, + placeholder: "+1 234-567-8900 (include country code)", + keyboardType: "phone-pad" as const, + inputType: "single" as const, + }, + address: { + id: "address", + label: "Location / Other", + description: "Address, place, or custom text", + iconName: "location" as const, + sfSymbol: "mappin.and.ellipse" as const, + placeholder: "Enter address or location details", + keyboardType: "default" as const, + inputType: "multiline" as const, + }, +} as const; + +export type LocationTypeId = keyof typeof LOCATION_TYPES; + +export interface EditLocationScreenProps { + booking: Booking | null; + onSuccess: () => void; + onSavingChange?: (isSaving: boolean) => void; +} + +// Handle type for parent component to call submit +export interface EditLocationScreenHandle { + submit: () => void; +} + +// Helper to detect location type from existing location string +const detectLocationType = (location: string): LocationTypeId => { + if (!location) return "link"; + + // Check if it's a URL + if ( + location.startsWith("http://") || + location.startsWith("https://") || + location.startsWith("www.") + ) { + return "link"; + } + + // Check if it looks like a phone number + const phoneRegex = /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/; + if (phoneRegex.test(location.replace(/\s/g, ""))) { + return "phone"; + } + + // Default to address for other text + return "address"; +}; + +export const EditLocationScreen = forwardRef( + function EditLocationScreen({ booking, onSuccess, onSavingChange }, ref) { + const insets = useSafeAreaInsets(); + const [selectedType, setSelectedType] = useState("link"); + const [inputValue, setInputValue] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + // Pre-fill with current location + useEffect(() => { + if (booking?.location) { + const detectedType = detectLocationType(booking.location); + setSelectedType(detectedType); + setInputValue(booking.location); + } + }, [booking?.location]); + + // Notify parent of saving state changes + useEffect(() => { + onSavingChange?.(isSaving); + }, [isSaving, onSavingChange]); + + const selectedTypeConfig = LOCATION_TYPES[selectedType]; + + const handleTypeSelect = useCallback( + (type: LocationTypeId) => { + if (selectedType !== type) { + setInputValue(""); + } + setSelectedType(type); + }, + [selectedType] + ); + + const handleSubmit = useCallback(async () => { + if (!booking) return; + + const trimmedValue = inputValue.trim(); + + if (!trimmedValue) { + Alert.alert("Error", "Please enter a location"); + return; + } + + // Validate phone number format + if (selectedType === "phone") { + if (!trimmedValue.startsWith("+")) { + Alert.alert( + "Invalid Phone Number", + "Phone number must include country code (e.g., +1 234-567-8900)" + ); + return; + } + } + + // Build the location payload based on type + let locationPayload: { type: string; [key: string]: string }; + + switch (selectedType) { + case "link": + locationPayload = { type: "link", link: trimmedValue }; + break; + case "phone": + locationPayload = { type: "phone", phone: trimmedValue }; + break; + case "address": + default: + locationPayload = { type: "address", address: trimmedValue }; + } + + setIsSaving(true); + try { + await CalComAPIService.updateLocationV2(booking.uid, locationPayload); + Alert.alert("Success", "Location updated successfully", [ + { text: "OK", onPress: onSuccess }, + ]); + } catch (error) { + console.error("[EditLocationScreen] Failed to update location:", error); + Alert.alert("Error", error instanceof Error ? error.message : "Failed to update location"); + } finally { + setIsSaving(false); + } + }, [booking, selectedType, inputValue, onSuccess]); + + // Expose submit function to parent via ref + useImperativeHandle( + ref, + () => ({ + submit: handleSubmit, + }), + [handleSubmit] + ); + + if (!booking) { + return ( + + No booking data + + ); + } + + return ( + + + {/* Current Location Info */} + {booking.location && ( + + + + + + Current Location + + {booking.location} + + + + )} + + {/* Location Type Selector with Native Context Menu */} + + Location Type + + + + + + + + {selectedTypeConfig.label} + + + {selectedTypeConfig.description} + + + + {/* Native iOS Context Menu Button */} + + + +