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:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user