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
This commit is contained in:
Dhairyashil Shinde
2025-12-29 18:57:48 +05:30
committed by GitHub
parent 3f003bafe5
commit b8e34bdc06
17 changed files with 1292 additions and 156 deletions
@@ -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<Booking | null>(null);
// Ref to store action handlers from BookingDetailScreen
const actionHandlersRef = useRef<ActionHandlers | null>(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 (
<>
<Stack.Screen
options={{
title: "Booking", // This appears in long-press navigation history
headerBackTitle: monthName, // This shows on the back button
headerBackButtonDisplayMode: "default",
headerTitle: "", // Hide the title in the header bar itself
headerStyle: {
backgroundColor: "#f2f2f7",
},
headerShadowVisible: false,
}}
/>
<Stack.Header style={{ shadowColor: "transparent", backgroundColor: "#f2f2f7" }}>
<Stack.Header.Right>
{/* Join Meeting Menu - only show if there's a meeting URL */}
{meetingUrl && (
<Stack.Header.Menu>
<Stack.Header.Icon sf="video" />
<Stack.Header.MenuAction icon="video" onPress={handleJoinMeeting}>
Join Meeting
</Stack.Header.MenuAction>
</Stack.Header.Menu>
)}
{/* Actions Menu */}
<Stack.Header.Menu>
<Stack.Header.Icon sf="ellipsis" />
{/* Copy Meeting Link - only show if there's a meeting URL */}
{meetingUrl && (
<Stack.Header.MenuAction icon="doc.on.doc" onPress={handleCopyMeetingLink}>
Copy Meeting Link
</Stack.Header.MenuAction>
)}
{/* Edit Event Section */}
<Stack.Header.Menu inline title="Edit Event">
{bookingActionsSections.editEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* After Event Section */}
<Stack.Header.Menu inline title="After Event">
{bookingActionsSections.afterEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* Danger Zone Submenu */}
<Stack.Header.Menu inline title="Danger Zone">
{bookingActionsSections.standalone.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
destructive
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
</Stack.Header.Menu>
</Stack.Header.Right>
</Stack.Header>
<BookingDetailScreen uid={uid} onActionsReady={handleActionsReady} />
</>
);
}
+1 -1
View File
@@ -395,7 +395,7 @@ export default function EventTypes() {
<View className="flex-1 bg-gray-100">
{Platform.OS === "web" && <Header />}
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Ionicons name="alert-circle" size={64} color="#800020" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
Unable to load event types
</Text>
+1 -1
View File
@@ -2029,7 +2029,7 @@ export default function EventTypeDetail() {
className="h-11 w-11 items-center justify-center"
onPress={handleDelete}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
<Ionicons name="trash-outline" size={20} color="#800020" />
</TouchableOpacity>
</GlassView>
</View>
@@ -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 (
<View
className="border-b border-cal-border bg-cal-bg"
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center">
<Pressable
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
accessibilityRole="button"
<View className="border-b border-cal-border bg-cal-bg">
{/* Native iOS Context Menu for long-press */}
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="longPress"
>
<View>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
</Pressable>
{/* Three dots menu - fixed hit target so it doesn't get squeezed off-screen */}
<View
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
>
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
activationMethod="singlePress"
<ContextMenu.Items>
{scheduleActions.map((scheduleAction) => (
<Button
key={scheduleAction.label}
systemImage={scheduleAction.icon}
onPress={scheduleAction.onPress}
role={scheduleAction.role}
label={scheduleAction.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<View
className="flex-row items-center"
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<ContextMenu.Items>
{scheduleActions.map((scheduleAction) => (
<Button
key={scheduleAction.label}
systemImage={scheduleAction.icon}
onPress={scheduleAction.onPress}
role={scheduleAction.role}
label={scheduleAction.label}
<Pressable
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
accessibilityRole="button"
>
<View>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots
availability={schedule.availability}
scheduleId={schedule.id}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image
systemName="ellipsis"
color="primary"
size={24}
modifiers={[frame({ height: 24, width: 17 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
</View>
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
</Pressable>
{/* Three dots menu - fixed hit target so it doesn't get squeezed off-screen */}
<View
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
>
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{scheduleActions.map((scheduleAction) => (
<Button
key={scheduleAction.label}
systemImage={scheduleAction.icon}
onPress={scheduleAction.onPress}
role={scheduleAction.role}
label={scheduleAction.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image
systemName="ellipsis"
color="primary"
size={24}
modifiers={[frame({ height: 24, width: 17 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
);
};
@@ -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 React from "react";
import { Pressable, View } from "react-native";
@@ -23,7 +23,6 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
isConfirming,
isDeclining,
onPress,
onLongPress,
onConfirm,
onReject,
onActionsPress: _onActionsPress,
@@ -139,23 +138,53 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
return (
<View className="border-b border-cal-border bg-cal-bg">
<Pressable
onPress={() => onPress(booking)}
onLongPress={() => onLongPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
className="active:bg-cal-bg-secondary"
{/* Native iOS Context Menu for long-press */}
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="longPress"
>
<ContextMenu.Items>
{contextMenuActions.map((action) => (
<Button
key={action.label}
systemImage={action.icon}
onPress={action.onPress}
role={action.role}
label={action.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<Pressable
onPress={() => onPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
className="active:bg-cal-bg-secondary"
>
<TimeAndDateRow
formattedDate={formattedDate}
formattedTimeRange={formattedTimeRange}
/>
<BadgesRow isPending={isPending} />
<BookingTitle
title={booking.title}
isCancelled={isCancelled}
isRejected={isRejected}
/>
<BookingDescription description={booking.description} />
<HostAndAttendees
hostAndAttendeesDisplay={hostAndAttendeesDisplay}
hasNoShowAttendee={hasNoShowAttendee}
/>
<MeetingLink meetingInfo={meetingInfo} />
</Pressable>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
<View
className="flex-row items-center justify-end"
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
>
<TimeAndDateRow formattedDate={formattedDate} formattedTimeRange={formattedTimeRange} />
<BadgesRow isPending={isPending} />
<BookingTitle title={booking.title} isCancelled={isCancelled} isRejected={isRejected} />
<BookingDescription description={booking.description} />
<HostAndAttendees
hostAndAttendeesDisplay={hostAndAttendeesDisplay}
hasNoShowAttendee={hasNoShowAttendee}
/>
<MeetingLink meetingInfo={meetingInfo} />
</Pressable>
<View className="flex-row items-center justify-end gap-2">
<ConfirmRejectButtons
booking={booking}
isPending={isPending}
@@ -168,7 +197,7 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
{/* iOS Context Menu */}
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
@@ -24,9 +24,9 @@ export function useBookingListItemData(booking: Booking, userEmail?: string): Bo
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isUpcoming = new Date(endTime) >= new Date();
const isPending = booking.status?.toUpperCase() === "PENDING";
const isCancelled = booking.status?.toUpperCase() === "CANCELLED";
const isRejected = booking.status?.toUpperCase() === "REJECTED";
const isPending = booking.status?.toLowerCase() === "pending";
const isCancelled = booking.status?.toLowerCase() === "cancelled";
const isRejected = booking.status?.toLowerCase() === "rejected";
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
const meetingInfo = getMeetingInfo(booking.location);
@@ -302,7 +302,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
{renderHeader?.()}
{renderFilterControls?.()}
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Ionicons name="alert-circle" size={64} color="#800020" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
Unable to load bookings
</Text>
@@ -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 type React from "react";
import { Pressable, View } from "react-native";
@@ -74,53 +74,76 @@ export const EventTypeListItem = ({
<View
className={`bg-cal-bg active:bg-cal-bg-secondary ${!isLast ? "border-b border-cal-border" : ""}`}
>
<View className="flex-shrink-1 flex-row items-center justify-between">
<Pressable
onPress={() => handleEventTypePress(item)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
className="flex-grow"
{/* Native iOS Context Menu for long-press */}
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="longPress"
>
<View className="mr-4 flex-1">
<EventTypeTitle title={item.title} />
<EventTypeDescription normalizedDescription={normalizedDescription} />
<DurationBadge formattedDuration={formattedDuration} />
<PriceAndConfirmationBadges
hasPrice={hasPrice}
formattedPrice={formattedPrice}
requiresConfirmation={item.requiresConfirmation}
/>
</View>
</Pressable>
<ContextMenu.Items>
{eventTypes.map((eventType) => (
<Button
key={eventType.label}
systemImage={eventType.icon}
onPress={eventType.onPress}
role={eventType.role}
label={eventType.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<View className="flex-shrink-1 flex-row items-center justify-between">
<Pressable
onPress={() => handleEventTypePress(item)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
className="flex-grow"
>
<View className="mr-4 flex-1">
<EventTypeTitle title={item.title} />
<EventTypeDescription normalizedDescription={normalizedDescription} />
<DurationBadge formattedDuration={formattedDuration} />
<PriceAndConfirmationBadges
hasPrice={hasPrice}
formattedPrice={formattedPrice}
requiresConfirmation={item.requiresConfirmation}
/>
</View>
</Pressable>
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{eventTypes.map((eventType) => (
<Button
key={eventType.label}
systemImage={eventType.icon}
onPress={eventType.onPress}
role={eventType.role}
label={eventType.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image
systemName="ellipsis"
color="primary"
size={24}
modifiers={[frame({ height: 24, width: 17 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
<View style={{ paddingRight: 16 }}>
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{eventTypes.map((eventType) => (
<Button
key={eventType.label}
systemImage={eventType.icon}
onPress={eventType.onPress}
role={eventType.role}
label={eventType.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image
systemName="ellipsis"
color="primary"
size={24}
modifiers={[frame({ height: 24, width: 17 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
);
};
@@ -206,7 +206,7 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
disabled={isSaving}
>
<Ionicons name="close-circle-outline" size={24} color="#FF3B30" />
<Ionicons name="close-circle-outline" size={24} color="#800020" />
</TouchableOpacity>
</View>
))}
@@ -636,7 +636,7 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
onPress={() => removeTimeSlot(dayIndex, slotIndex + 1)}
className="p-1"
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
<Ionicons name="trash-outline" size={20} color="#800020" />
</AppPressable>
</View>
))}
@@ -691,7 +691,7 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
<Ionicons name="pencil-outline" size={20} color="#007AFF" />
</AppPressable>
<AppPressable onPress={() => handleDeleteOverride(index)} className="p-2">
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
<Ionicons name="trash-outline" size={20} color="#800020" />
</AppPressable>
</View>
</View>
@@ -747,7 +747,7 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
className="h-11 w-11 items-center justify-center"
onPress={handleDelete}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
<Ionicons name="trash-outline" size={20} color="#800020" />
</AppPressable>
</GlassView>
</View>
@@ -294,7 +294,7 @@ export function AvailabilityListScreen({
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Ionicons name="alert-circle" size={64} color="#800020" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-[#333]">
Unable to load availability
</Text>
@@ -0,0 +1,677 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
// Format date for iOS Calendar style: "Thursday, 25 Dec 2025"
const formatDateCalendarStyle = (dateString: string): string => {
if (!dateString) return "";
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("en-US", {
weekday: "long",
day: "numeric",
month: "short",
year: "numeric",
});
};
// Format time for iOS Calendar style: "11:30 AM - 12 PM"
const formatTimeCalendarStyle = (startDateString: string, endDateString: string): string => {
if (!startDateString || !endDateString) return "";
const startDate = new Date(startDateString);
const endDate = new Date(endDateString);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return "";
const formatTime = (date: Date): string => {
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? "PM" : "AM";
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
if (minutes === 0) {
return `${hour12} ${period}`;
}
return `${hour12}:${minutes.toString().padStart(2, "0")} ${period}`;
};
return `${formatTime(startDate)} – ${formatTime(endDate)}`;
};
// Format duration for display: "30 min" or "1 hr" or "1 hr 30 min"
const formatDuration = (minutes: number): string => {
if (minutes < 60) {
return `${minutes} min`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return hours === 1 ? "1 hr" : `${hours} hrs`;
}
return `${hours} hr ${remainingMinutes} min`;
};
// Calculate duration from start and end times
const calculateDuration = (startDateString: string, endDateString: string): number => {
if (!startDateString || !endDateString) return 0;
const startDate = new Date(startDateString);
const endDate = new Date(endDateString);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return 0;
return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60));
};
export interface BookingDetailScreenProps {
uid: string;
onActionsReady?: (handlers: {
openRescheduleModal: () => void;
openEditLocationModal: () => void;
openAddGuestsModal: () => void;
openViewRecordingsModal: () => void;
openMeetingSessionDetailsModal: () => void;
openMarkNoShowModal: () => void;
handleCancelBooking: () => void;
}) => void;
}
export function BookingDetailScreen({
uid,
onActionsReady,
}: BookingDetailScreenProps): React.JSX.Element {
const router = useRouter();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const [participantsExpanded, setParticipantsExpanded] = useState(true);
const performCancelBooking = useCallback(
async (reason: string) => {
if (!booking) return;
setIsCancelling(true);
try {
await CalComAPIService.cancelBooking(booking.uid, reason);
Alert.alert("Success", "Booking cancelled successfully", [
{
text: "OK",
onPress: () => router.back(),
},
]);
setIsCancelling(false);
} catch (err) {
console.error("Failed to cancel booking");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
console.debug("[BookingDetailScreen.ios] cancelBooking failed", { message });
}
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
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: () => {
Alert.prompt(
"Cancellation Reason",
"Please provide a reason for cancelling this booking:",
[
{ text: "Cancel", style: "cancel" },
{
text: "Cancel Booking",
style: "destructive",
onPress: (reason?: string) => {
performCancelBooking(reason?.trim() || "Cancelled by host");
},
},
],
"plain-text",
"",
"default"
);
},
},
]);
}, [booking, performCancelBooking]);
const openRescheduleModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/(tabs)/(bookings)/reschedule",
params: { uid: booking.uid },
});
}, [booking, router]);
const openEditLocationModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/(tabs)/(bookings)/edit-location",
params: { uid: booking.uid },
});
}, [booking, router]);
const openAddGuestsModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/(tabs)/(bookings)/add-guests",
params: { uid: booking.uid },
});
}, [booking, router]);
const openMarkNoShowModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/(tabs)/(bookings)/mark-no-show",
params: { uid: booking.uid },
});
}, [booking, router]);
const openViewRecordingsModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/(tabs)/(bookings)/view-recordings",
params: { uid: booking.uid },
});
}, [booking, router]);
const openMeetingSessionDetailsModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/(tabs)/(bookings)/meeting-session-details",
params: { uid: booking.uid },
});
}, [booking, router]);
const fetchBooking = useCallback(async () => {
setLoading(true);
setError(null);
let bookingData: Booking | null = null;
let fetchError: Error | null = null;
try {
bookingData = await CalComAPIService.getBookingByUid(uid);
} catch (err) {
fetchError = err instanceof Error ? err : new Error(String(err));
}
if (bookingData) {
if (__DEV__) {
const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
const attendeeCount = bookingData.attendees?.length ?? 0;
console.debug("[BookingDetailScreen.ios] booking fetched", {
uid: bookingData.uid,
status: bookingData.status,
hostCount,
attendeeCount,
hasRecurringEventId: Boolean(bookingData.recurringEventId),
});
}
setBooking(bookingData);
setLoading(false);
} else {
console.error("Error fetching booking");
if (__DEV__ && fetchError) {
console.debug("[BookingDetailScreen.ios] fetchBooking failed", {
message: fetchError.message,
stack: fetchError.stack,
});
}
setError("Failed to load booking. Please try again.");
if (__DEV__) {
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} else {
router.back();
}
setLoading(false);
}
}, [uid, router]);
useEffect(() => {
if (uid) {
fetchBooking();
} else {
setLoading(false);
setError("Invalid booking ID");
}
}, [uid, fetchBooking]);
useEffect(() => {
if (booking && onActionsReady) {
onActionsReady({
openRescheduleModal,
openEditLocationModal,
openAddGuestsModal,
openViewRecordingsModal,
openMeetingSessionDetailsModal,
openMarkNoShowModal,
handleCancelBooking,
});
}
}, [
booking,
onActionsReady,
openRescheduleModal,
openEditLocationModal,
openAddGuestsModal,
openViewRecordingsModal,
openMeetingSessionDetailsModal,
handleCancelBooking,
openMarkNoShowModal,
]);
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-[#f2f2f7]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-gray-500">Loading booking...</Text>
</View>
);
}
if (error || !booking) {
return (
<View className="flex-1 items-center justify-center bg-[#f2f2f7] p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{error || "Booking not found"}
</Text>
<AppPressable className="mt-6 rounded-lg bg-black px-6 py-3" onPress={() => router.back()}>
<Text className="text-base font-semibold text-white">Go Back</Text>
</AppPressable>
</View>
);
}
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const dateFormatted = formatDateCalendarStyle(startTime);
const timeFormatted = formatTimeCalendarStyle(startTime, endTime);
const isRecurring =
booking.recurringEventId || (booking as { recurringBookingUid?: string }).recurringBookingUid;
const duration = booking.duration || calculateDuration(startTime, endTime);
const durationFormatted = duration > 0 ? formatDuration(duration) : null;
const eventTypeSlug = booking.eventType?.slug;
const hostsCount = booking.hosts?.length || (booking.user ? 1 : 0);
const attendeesCount = booking.attendees?.length || 0;
const guestsCount = booking.guests?.length || 0;
const totalParticipants = hostsCount + attendeesCount + guestsCount;
const isPastBooking = new Date(endTime) < new Date();
const normalizedStatus = booking.status.toLowerCase();
const getAttendeeStatusIcon = (attendee: { noShow?: boolean; absent?: boolean }) => {
const isNoShow = attendee.noShow || attendee.absent;
if (isPastBooking && isNoShow) {
return { name: "close-circle" as const, color: "#FF3B30", label: "No-show" };
}
if (normalizedStatus === "pending") {
return { name: "help-circle" as const, color: "#8E8E93", label: "Pending" };
}
if (normalizedStatus === "cancelled" || normalizedStatus === "rejected") {
return { name: "close-circle-outline" as const, color: "#8E8E93", label: null };
}
return { name: "checkmark-circle" as const, color: "#34C759", label: null };
};
return (
<View className="flex-1 bg-[#f2f2f7]">
<ScrollView
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: insets.bottom + 100,
}}
showsVerticalScrollIndicator={false}
>
{/* Title Section - iOS Calendar Style */}
<View className="mb-8">
{/* Meeting Title */}
<Text
className="mb-4 text-[26px] font-semibold leading-tight text-black"
style={{ letterSpacing: -0.3 }}
>
{booking.title}
</Text>
{/* Event Type Slug and Duration Badge */}
{(eventTypeSlug || durationFormatted) && (
<View className="mb-3 flex-row items-center">
{eventTypeSlug && <Text className="text-[15px] text-[#8E8E93]">{eventTypeSlug}</Text>}
{eventTypeSlug && durationFormatted && (
<Text className="mx-2 text-[15px] text-[#C7C7CC]">•</Text>
)}
{durationFormatted && (
<View className="rounded-full bg-[#E5E5EA] px-2.5 py-1">
<Text className="text-[13px] font-medium text-[#636366]">
{durationFormatted}
</Text>
</View>
)}
</View>
)}
{/* Date */}
<Text className="mb-0.5 text-[17px] text-black">{dateFormatted}</Text>
{/* Time */}
<Text className="mb-0.5 text-[17px] text-black">{timeFormatted}</Text>
{/* Recurring indicator */}
{isRecurring && <Text className="mt-0.5 text-[17px] text-[#800020]">Repeats weekly</Text>}
</View>
{/* Participants Card - iOS Calendar Style (Expandable) */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<AppPressable
className="flex-row items-center justify-between px-4 py-3.5"
onPress={() => setParticipantsExpanded(!participantsExpanded)}
>
<Text className="text-[17px] text-black">Participants</Text>
<View className="flex-row items-center">
<Text className="mr-1 text-[17px] text-[#8E8E93]">{totalParticipants}</Text>
<Ionicons
name={participantsExpanded ? "chevron-down" : "chevron-forward"}
size={18}
color="#C7C7CC"
/>
</View>
</AppPressable>
{/* Participants list - only show when expanded */}
{participantsExpanded && (
<View className="border-t border-[#E5E5EA] px-4 py-2.5">
{/* Hosts */}
{booking.hosts && booking.hosts.length > 0
? booking.hosts.map((host, index) => (
<View
key={host.email || `host-${index}`}
className={`flex-row items-center py-2 ${index > 0 ? "border-t border-[#E5E5EA]" : ""}`}
>
<Ionicons name="star" size={18} color="#FFD60A" />
<Text className="ml-2.5 flex-1 text-[15px] text-black" numberOfLines={1}>
{host.name || host.email || "Host"}
</Text>
<Text className="text-[13px] text-[#8E8E93]">Organizer</Text>
</View>
))
: booking.user && (
<View className="flex-row items-center py-2">
<Ionicons name="star" size={18} color="#FFD60A" />
<Text className="ml-2.5 flex-1 text-[15px] text-black" numberOfLines={1}>
{booking.user.name || booking.user.email}
</Text>
<Text className="text-[13px] text-[#8E8E93]">Organizer</Text>
</View>
)}
{/* Attendees */}
{booking.attendees?.map((attendee, index) => {
const statusIcon = getAttendeeStatusIcon(attendee);
return (
<View
key={attendee.email}
className={`flex-row items-center py-2 ${
index > 0 || booking.hosts?.length || booking.user
? "border-t border-[#E5E5EA]"
: ""
}`}
>
<Ionicons name={statusIcon.name} size={20} color={statusIcon.color} />
<Text className="ml-2.5 flex-1 text-[15px] text-black" numberOfLines={1}>
{attendee.name || attendee.email}
</Text>
{statusIcon.label && (
<Text className="text-[13px]" style={{ color: statusIcon.color }}>
{statusIcon.label}
</Text>
)}
</View>
);
})}
{/* Guests */}
{booking.guests?.map((guestEmail, index) => (
<View
key={guestEmail}
className={`flex-row items-center py-2 ${
index > 0 || booking.attendees?.length || booking.hosts?.length || booking.user
? "border-t border-[#E5E5EA]"
: ""
}`}
>
<Ionicons name="person-outline" size={20} color="#8E8E93" />
<Text className="ml-2.5 flex-1 text-[15px] text-[#8E8E93]" numberOfLines={1}>
{guestEmail}
</Text>
<Text className="text-[13px] text-[#8E8E93]">Guest</Text>
</View>
))}
</View>
)}
</View>
{/* Custom Fields Card (if available) */}
{(() => {
if (!booking.bookingFieldsResponses) return null;
const excludedKeys = [
"location",
"guests",
"rescheduledReason",
"rescheduleReason",
"email",
"name",
"notes",
"description",
"additionalNotes",
];
const displayableEntries = Object.entries(booking.bookingFieldsResponses).filter(
([key, value]) => {
if (excludedKeys.includes(key)) return false;
if (value === null || value === undefined || value === "") return false;
if (Array.isArray(value) && value.length === 0) return false;
if (typeof value === "object" && !Array.isArray(value) && value !== null) {
const obj = value as Record<string, unknown>;
if ("value" in obj && "optionValue" in obj) return false;
if (Object.keys(obj).length === 0) return false;
}
return true;
}
);
if (displayableEntries.length === 0) return null;
return (
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<View className="px-4 py-3.5">
<Text className="mb-2.5 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
Booking Details
</Text>
{displayableEntries.map(([key, value], index) => {
let displayValue: string;
if (typeof value === "string") {
displayValue = value;
} else if (Array.isArray(value)) {
displayValue = value.join(", ");
} else if (typeof value === "boolean") {
displayValue = value ? "Yes" : "No";
} else if (typeof value === "number") {
displayValue = String(value);
} else {
return null;
}
const displayKey = key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (str) => str.toUpperCase())
.trim();
return (
<View
key={key}
className={`py-2 ${index > 0 ? "border-t border-[#E5E5EA]" : ""}`}
>
<Text className="mb-0.5 text-[13px] text-[#8E8E93]">{displayKey}</Text>
<Text className="text-[17px] text-black">{displayValue}</Text>
</View>
);
})}
</View>
</View>
);
})()}
{/* Description Card (if available) */}
{booking.description && (
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<View className="px-4 py-3.5">
<Text className="mb-1.5 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
Notes
</Text>
<Text className="text-[17px] leading-6 text-black">{booking.description}</Text>
</View>
</View>
)}
{/* Rescheduling Info Card (if applicable) */}
{(booking.rescheduledFromUid ||
booking.rescheduledToUid ||
booking.reschedulingReason ||
booking.fromReschedule) && (
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<View className="px-4 py-3.5">
<Text className="mb-2.5 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
Rescheduling Info
</Text>
{(booking.rescheduledFromUid || booking.fromReschedule) && (
<AppPressable
className="flex-row items-center justify-between py-2"
onPress={() => {
const fromUid = booking.rescheduledFromUid || booking.fromReschedule;
if (fromUid) {
router.push({
pathname: "/(tabs)/(bookings)/booking-detail",
params: { uid: fromUid },
});
}
}}
>
<Text className="text-[17px] text-black">Rescheduled from</Text>
<View className="flex-row items-center">
<Text className="mr-1 text-[15px] text-[#007AFF]">View original</Text>
<Ionicons name="chevron-forward" size={16} color="#007AFF" />
</View>
</AppPressable>
)}
{booking.rescheduledToUid && (
<AppPressable
className="flex-row items-center justify-between border-t border-[#E5E5EA] py-2"
onPress={() => {
router.push({
pathname: "/(tabs)/(bookings)/booking-detail",
params: { uid: booking.rescheduledToUid },
});
}}
>
<Text className="text-[17px] text-black">Rescheduled to</Text>
<View className="flex-row items-center">
<Text className="mr-1 text-[15px] text-[#007AFF]">View new</Text>
<Ionicons name="chevron-forward" size={16} color="#007AFF" />
</View>
</AppPressable>
)}
{booking.reschedulingReason && (
<View
className={`py-2 ${booking.rescheduledFromUid || booking.rescheduledToUid || booking.fromReschedule ? "border-t border-[#E5E5EA]" : ""}`}
>
<Text className="mb-0.5 text-[13px] text-[#8E8E93]">Reason</Text>
<Text className="text-[17px] text-black">{booking.reschedulingReason}</Text>
</View>
)}
</View>
</View>
)}
{/* Cancellation Info Card (if cancelled) */}
{normalizedStatus === "cancelled" &&
(booking.cancellationReason || booking.cancelledByEmail || booking.absentHost) && (
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<View className="px-4 py-3.5">
<Text className="mb-2.5 text-[13px] font-medium uppercase tracking-wide text-[#FF3B30]">
Cancellation Details
</Text>
{booking.cancellationReason && (
<View className="py-2">
<Text className="mb-0.5 text-[13px] text-[#8E8E93]">Reason</Text>
<Text className="text-[17px] text-black">{booking.cancellationReason}</Text>
</View>
)}
{booking.cancelledByEmail && (
<View
className={`py-2 ${booking.cancellationReason ? "border-t border-[#E5E5EA]" : ""}`}
>
<Text className="mb-0.5 text-[13px] text-[#8E8E93]">Cancelled by</Text>
<Text className="text-[17px] text-black">{booking.cancelledByEmail}</Text>
</View>
)}
{booking.absentHost && (
<View
className={`flex-row items-center py-2 ${booking.cancellationReason || booking.cancelledByEmail ? "border-t border-[#E5E5EA]" : ""}`}
>
<Ionicons name="warning" size={18} color="#FF9500" />
<Text className="ml-2 text-[17px] text-black">Host was absent</Text>
</View>
)}
</View>
</View>
)}
</ScrollView>
{/* Cancelling overlay */}
{isCancelling && (
<View className="absolute inset-0 items-center justify-center bg-black/50">
<View className="rounded-2xl bg-white px-8 py-6">
<ActivityIndicator size="large" color="#000" />
<Text className="mt-3 text-base font-medium text-gray-700">Cancelling booking...</Text>
</View>
</View>
)}
</View>
);
}
@@ -176,7 +176,6 @@ export interface BookingDetailScreenProps {
}
export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps) {
"use no memo";
const router = useRouter();
const { userInfo } = useAuth();
@@ -322,26 +321,36 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
const fetchBooking = useCallback(async () => {
setLoading(true);
setError(null);
let bookingData: Booking | null = null;
let fetchError: Error | null = null;
try {
const bookingData = await CalComAPIService.getBookingByUid(uid);
// Avoid logging booking payloads: they may contain PII (names/emails/notes).
bookingData = await CalComAPIService.getBookingByUid(uid);
} catch (err) {
fetchError = err instanceof Error ? err : new Error(String(err));
}
if (bookingData) {
if (__DEV__) {
const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
const attendeeCount = bookingData.attendees?.length ?? 0;
console.debug("[BookingDetailScreen] booking fetched", {
uid: bookingData.uid,
status: bookingData.status,
hostCount: bookingData.hosts?.length ?? (bookingData.user ? 1 : 0),
attendeeCount: bookingData.attendees?.length ?? 0,
hostCount,
attendeeCount,
hasRecurringEventId: Boolean(bookingData.recurringEventId),
});
}
setBooking(bookingData);
setLoading(false);
} catch (err) {
} else {
console.error("Error fetching booking");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
console.debug("[BookingDetailScreen] fetchBooking failed", { message, stack });
if (__DEV__ && fetchError) {
console.debug("[BookingDetailScreen] fetchBooking failed", {
message: fetchError.message,
stack: fetchError.stack,
});
}
setError("Failed to load booking. Please try again.");
if (__DEV__) {
@@ -358,6 +367,9 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
useEffect(() => {
if (uid) {
fetchBooking();
} else {
setLoading(false);
setError("Invalid booking ID");
}
}, [uid, fetchBooking]);
@@ -407,7 +419,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
if (error || !booking) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa] p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Ionicons name="alert-circle" size={64} color="#800020" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{error || "Booking not found"}
</Text>
+4 -4
View File
@@ -111,10 +111,10 @@ export function useBookingStatusFlags(booking: Booking | null) {
isUpcoming,
isPast,
isOngoing,
isCancelled: normalized.status === "CANCELLED",
isRejected: normalized.status === "REJECTED",
isPending: normalized.status === "PENDING",
isConfirmed: normalized.status === "ACCEPTED",
isCancelled: normalized.status === "cancelled",
isRejected: normalized.status === "rejected",
isPending: normalized.status === "pending",
isConfirmed: normalized.status === "accepted",
};
}, [booking]);
}
+14 -1
View File
@@ -1,3 +1,5 @@
export type BookingStatus = "accepted" | "pending" | "cancelled" | "rejected";
export interface Booking {
id: number;
uid: string;
@@ -32,8 +34,9 @@ export interface Booking {
name: string;
timeZone: string;
noShow?: boolean;
absent?: boolean;
}>;
status: "ACCEPTED" | "PENDING" | "CANCELLED" | "REJECTED";
status: BookingStatus;
paid?: boolean;
payment?: Array<{
id: number;
@@ -49,6 +52,16 @@ export interface Booking {
cancellationReason?: string;
rejectionReason?: string;
responses?: Record<string, unknown>;
// Additional fields from API
guests?: string[];
bookingFieldsResponses?: Record<string, unknown>;
rescheduledFromUid?: string;
rescheduledToUid?: string;
reschedulingReason?: string;
cancelledByEmail?: string;
absentHost?: boolean;
duration?: number;
meetingUrl?: string;
}
export interface GetBookingsResponse {
+8 -9
View File
@@ -11,7 +11,7 @@
* Deferred:
* - report, reroute, reassign, charge_card
*/
import type { Booking } from "@/services/types/bookings.types";
import type { Booking, BookingStatus } from "@/services/types/bookings.types";
import type { EventType } from "@/services/types/event-types.types";
// ============================================================================
@@ -102,7 +102,7 @@ export interface NormalizedBooking {
}>;
}
export type BookingStatus = "ACCEPTED" | "PENDING" | "CANCELLED" | "REJECTED";
export type { BookingStatus } from "@/services/types/bookings.types";
// ============================================================================
// Normalization Functions
@@ -110,13 +110,12 @@ export type BookingStatus = "ACCEPTED" | "PENDING" | "CANCELLED" | "REJECTED";
/**
* Normalizes a booking object from API response to a consistent format.
* - Normalizes status to uppercase
* - Normalizes status to lowercase (API v2 format)
* - Converts time strings to Date objects
* - Ensures consistent field names
*/
export function normalizeBooking(booking: Booking): NormalizedBooking {
// Normalize status to uppercase (API v2 may return lowercase)
const status = (booking.status?.toUpperCase() || "PENDING") as BookingStatus;
const status = (booking.status?.toLowerCase() || "pending") as BookingStatus;
// Normalize times - prefer startTime/endTime, fallback to start/end
const startTimeStr = booking.startTime || booking.start || "";
@@ -177,28 +176,28 @@ export function isBookingOngoing(booking: NormalizedBooking, now: Date = new Dat
* Check if booking is cancelled.
*/
export function isBookingCancelled(booking: NormalizedBooking): boolean {
return booking.status === "CANCELLED";
return booking.status === "cancelled";
}
/**
* Check if booking is rejected.
*/
export function isBookingRejected(booking: NormalizedBooking): boolean {
return booking.status === "REJECTED";
return booking.status === "rejected";
}
/**
* Check if booking is pending (unconfirmed).
*/
export function isBookingPending(booking: NormalizedBooking): boolean {
return booking.status === "PENDING";
return booking.status === "pending";
}
/**
* Check if booking is confirmed (accepted).
*/
export function isBookingConfirmed(booking: NormalizedBooking): boolean {
return booking.status === "ACCEPTED";
return booking.status === "accepted";
}
// ============================================================================
+5 -5
View File
@@ -275,15 +275,15 @@ export const formatDateTime = (dateString: string): string => {
*/
export const getStatusColor = (status: string): string => {
// API v2 2024-08-13 returns status in lowercase, so normalize to uppercase for comparison
const normalizedStatus = status.toUpperCase();
const normalizedStatus = status.toLowerCase();
switch (normalizedStatus) {
case "ACCEPTED":
case "accepted":
return "#34C759";
case "PENDING":
case "pending":
return "#FF9500";
case "CANCELLED":
case "cancelled":
return "#FF3B30";
case "REJECTED":
case "rejected":
return "#FF3B30";
default:
return "#666";