Files
Dhairyashil ShindeandGitHub fa53f54815 feat(companion): add configurable landing page (#27267)
* feat(companion): add configurable landing page feature

- Add useUserPreferences hook for persistent storage of landing page preference
- Add LandingPagePicker component for both iOS and Android/web platforms
- Update tabs index to redirect based on user preference
- Update bookings index to accept initial filter from URL params
- Add App Settings section in More screen with landing page selector
- Clear user preferences on logout for fresh state

* fix(companion): remove try-finally for React Compiler compatibility

* fix(companion): use router.replace for dynamic landing page redirect

Replace Redirect component with router.replace() to fix TypeScript
strict typing issue with expo-router's Href type for dynamic routes.

* fix(companion): use literal route strings for TypeScript strict typing

Use switch statement with literal route strings instead of dynamic
string variable to satisfy expo-router's strict Href type checking.

* working fix

* better css
2026-02-13 11:28:42 -03:00

1078 lines
38 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { useMemo, useState } from "react";
import {
Alert,
FlatList,
Platform,
RefreshControl,
ScrollView,
Text,
TextInput,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { FullScreenModal } from "@/components/FullScreenModal";
import { BookingListItem } from "@/components/booking-list-item/BookingListItem";
import { BookingListSkeleton } from "@/components/booking-list-item/BookingListItemSkeleton";
import { RecurringBookingListItem } from "@/components/booking-list-item/RecurringBookingListItem";
import { BookingModals } from "@/components/booking-modals/BookingModals";
import { EmptyScreen } from "@/components/EmptyScreen";
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Text as UIText } from "@/components/ui/text";
import { useAuth } from "@/contexts/AuthContext";
import {
type BookingFilter,
useBookingActions,
useBookings,
useCancelBooking,
useConfirmBooking,
useDeclineBooking,
useRescheduleBooking,
} from "@/hooks";
import type { Booking, EventType } from "@/services/calcom";
import type { ListItem, RecurringBookingGroup } from "@/utils/bookings-utils";
import {
filterByEventType,
getEmptyStateContent,
groupBookingsByMonth,
groupRecurringBookings,
searchBookings,
} from "@/utils/bookings-utils";
import { offlineAwareRefresh } from "@/utils/network";
import { getColors } from "@/constants/colors";
interface BookingListScreenProps {
// Platform-specific header rendering
renderHeader?: () => React.ReactNode;
// Platform-specific filter controls
renderFilterControls?: () => React.ReactNode;
// Whether to show the filter modal (only for non-iOS)
showFilterModal?: boolean;
setShowFilterModal?: (show: boolean) => void;
// Event types for filtering
eventTypes?: EventType[];
eventTypesLoading?: boolean;
// Search query (controlled from parent for iOS headerSearchBar)
searchQuery: string;
onSearchChange?: (query: string) => void;
// Event type filter (controlled from parent)
selectedEventTypeId: number | null;
onEventTypeChange?: (id: number | null, label?: string | null) => void;
// Active filter (controlled from parent)
activeFilter: BookingFilter;
filterParams: Record<string, unknown>;
// iOS-style list (no wrapper)
iosStyle?: boolean;
}
export const BookingListScreen: React.FC<BookingListScreenProps> = ({
renderHeader,
renderFilterControls,
showFilterModal,
setShowFilterModal,
eventTypes,
eventTypesLoading,
searchQuery,
selectedEventTypeId,
onEventTypeChange,
activeFilter,
filterParams,
iosStyle = false,
}) => {
const router = useRouter();
const { userInfo } = useAuth();
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
// Use React Query hook for fetching bookings
const {
data: rawBookings = [],
isLoading: loading,
error: queryError,
refetch,
} = useBookings(filterParams);
// Cancel booking mutation
const { mutate: cancelBookingMutation } = useCancelBooking();
// Confirm booking mutation
const { mutate: confirmBookingMutation, isPending: isConfirming } = useConfirmBooking();
// Decline booking mutation
const { mutate: declineBookingMutation, isPending: isDeclining } = useDeclineBooking();
// Reschedule booking mutation
const { mutate: rescheduleBookingMutation, isPending: isRescheduling } = useRescheduleBooking();
// Booking actions hook
const {
showRejectModal,
rejectReason,
setRejectReason,
showCancelModal,
cancelBooking,
cancelReason,
setCancelReason,
handleSubmitCancel,
handleCloseCancelModal,
selectedBooking,
handleBookingPress,
handleCancelBooking,
handleInlineConfirm,
handleOpenRejectModal,
handleSubmitReject,
handleCloseRejectModal,
} = useBookingActions({
router,
cancelMutation: cancelBookingMutation,
confirmMutation: confirmBookingMutation,
declineMutation: declineBookingMutation,
rescheduleMutation: rescheduleBookingMutation,
isConfirming,
isDeclining,
isRescheduling,
});
// Navigate to reschedule screen (same pattern as booking detail)
const handleNavigateToReschedule = React.useCallback(
(booking: Booking) => {
router.push({
pathname: "/reschedule",
params: { uid: booking.uid },
});
},
[router]
);
// Navigate to edit location screen (same pattern as booking detail)
const handleNavigateToEditLocation = React.useCallback(
(booking: Booking) => {
router.push({
pathname: "/edit-location",
params: { uid: booking.uid },
});
},
[router]
);
// Navigate to add guests screen (same pattern as booking detail)
const handleNavigateToAddGuests = React.useCallback(
(booking: Booking) => {
router.push({
pathname: "/add-guests",
params: { uid: booking.uid },
});
},
[router]
);
// Navigate to mark no show screen (same pattern as booking detail)
const handleNavigateToMarkNoShow = React.useCallback(
(booking: Booking) => {
router.push({
pathname: "/mark-no-show",
params: { uid: booking.uid },
});
},
[router]
);
// Navigate to view recordings screen (same pattern as booking detail)
const handleNavigateToViewRecordings = React.useCallback(
(booking: Booking) => {
router.push({
pathname: "/view-recordings",
params: { uid: booking.uid },
});
},
[router]
);
// Navigate to meeting session details screen (same pattern as booking detail)
const handleNavigateToMeetingSessionDetails = React.useCallback(
(booking: Booking) => {
router.push({
pathname: "/meeting-session-details",
params: { uid: booking.uid },
});
},
[router]
);
// Sort bookings based on active filter
const bookings = useMemo(() => {
if (!rawBookings || !Array.isArray(rawBookings)) return [];
const sorted = [...rawBookings];
switch (activeFilter) {
case "upcoming":
case "unconfirmed":
// Sort by start time ascending
return sorted.sort(
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
case "past":
case "cancelled":
// Sort by start time descending (latest first)
return sorted.sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
);
default:
return sorted;
}
}, [rawBookings, activeFilter]);
// Convert query error to string
// Don't show error UI for authentication errors (user will be redirected to login)
// Only show error UI in development mode for other errors
const isAuthError =
queryError?.message?.includes("Authentication") ||
queryError?.message?.includes("sign in") ||
queryError?.message?.includes("401");
const error = queryError && !isAuthError && __DEV__ ? "Failed to load bookings." : null;
// Prevents showing loading indicator when refreshing or changing filters
function manualRefresh() {
setIsManualRefreshing(true);
offlineAwareRefresh(refetch).finally(() => {
setIsManualRefreshing(false);
});
}
// Apply local filters (search and event type) using useMemo
const filteredBookings = useMemo(() => {
let filtered = bookings;
// Apply event type filter
filtered = filterByEventType(filtered, selectedEventTypeId);
// Apply search filter
filtered = searchBookings(filtered, searchQuery);
return filtered;
}, [bookings, searchQuery, selectedEventTypeId]);
// Generate list items based on filter type
const listItems = useMemo<ListItem[]>(() => {
if (activeFilter === "recurring") {
// For recurring filter, group bookings by recurringBookingUid
const groups = groupRecurringBookings(filteredBookings);
return groups.map((group) => ({
type: "recurringGroup" as const,
group,
key: `recurring-${group.recurringBookingUid}`,
}));
}
if (activeFilter === "unconfirmed") {
// For unconfirmed filter, group recurring bookings but keep non-recurring separate
const recurringBookings: Booking[] = [];
const nonRecurringBookings: Booking[] = [];
filteredBookings.forEach((booking) => {
if (booking.recurringBookingUid) {
recurringBookings.push(booking);
} else {
nonRecurringBookings.push(booking);
}
});
// Group recurring bookings
const recurringGroups = groupRecurringBookings(recurringBookings);
const recurringItems: ListItem[] = recurringGroups.map((group) => ({
type: "recurringGroup" as const,
group,
key: `recurring-${group.recurringBookingUid}`,
}));
// Keep non-recurring bookings with month grouping
const nonRecurringItems = groupBookingsByMonth(nonRecurringBookings);
// Combine: recurring groups first, then non-recurring
return [...recurringItems, ...nonRecurringItems];
}
// For other filters, use month grouping
return groupBookingsByMonth(filteredBookings);
}, [filteredBookings, activeFilter]);
// State for bulk action loading
const [isCancellingAll, setIsCancellingAll] = React.useState(false);
const [isConfirmingAll, setIsConfirmingAll] = React.useState(false);
const [isDecliningAll, setIsDecliningAll] = React.useState(false);
// Android dialog state for Cancel All
const [showCancelAllDialog, setShowCancelAllDialog] = React.useState(false);
const [cancelAllGroup, setCancelAllGroup] = React.useState<RecurringBookingGroup | null>(null);
const [cancelAllReason, setCancelAllReason] = React.useState("");
// Android dialog state for Reject All
const [showRejectAllDialog, setShowRejectAllDialog] = React.useState(false);
const [rejectAllGroup, setRejectAllGroup] = React.useState<RecurringBookingGroup | null>(null);
const [rejectAllReason, setRejectAllReason] = React.useState("");
// Handle recurring group press - navigate to first upcoming booking
const handleRecurringGroupPress = React.useCallback(
(group: RecurringBookingGroup) => {
handleBookingPress(group.firstUpcoming);
},
[handleBookingPress]
);
// Cancel all remaining bookings in a recurring series
const handleCancelAllRemaining = React.useCallback(
async (group: RecurringBookingGroup) => {
// For Android, open dialog with reason input
if (Platform.OS === "android") {
setCancelAllGroup(group);
setCancelAllReason("");
setShowCancelAllDialog(true);
return;
}
// For iOS, use Alert.prompt
Alert.alert(
"Cancel All Remaining",
`Are you sure you want to cancel all ${group.remainingCount} remaining bookings in this series?`,
[
{ text: "No", style: "cancel" },
{
text: "Yes, Cancel All",
style: "destructive",
onPress: () => {
Alert.prompt(
"Cancellation Reason",
"Please provide a reason for cancelling these bookings:",
[
{ text: "Cancel", style: "cancel" },
{
text: "Cancel All",
style: "destructive",
onPress: (reason?: string) => {
const cancellationReason = reason?.trim() || "Cancelled all remaining";
setIsCancellingAll(true);
cancelBookingMutation(
{
uid: group.recurringBookingUid,
reason: cancellationReason,
},
{
onSuccess: () => {
showSuccessAlert(
"Success",
"All remaining bookings have been cancelled."
);
setIsCancellingAll(false);
},
onError: () => {
showErrorAlert("Error", "Failed to cancel bookings. Please try again.");
setIsCancellingAll(false);
},
}
);
},
},
],
"plain-text",
"",
"default"
);
},
},
]
);
},
[cancelBookingMutation]
);
// Confirm all unconfirmed bookings in a recurring series
const handleConfirmAll = React.useCallback(
async (group: RecurringBookingGroup) => {
const unconfirmedBookings = group.bookings.filter(
(b) =>
b.status?.toLowerCase() === "pending" ||
b.status?.toLowerCase() === "requires_confirmation" ||
b.requiresConfirmation
);
if (unconfirmedBookings.length === 0) {
showInfoAlert("Info", "No unconfirmed bookings to confirm.");
return;
}
Alert.alert(
"Confirm All",
`Are you sure you want to confirm ${unconfirmedBookings.length} unconfirmed bookings?`,
[
{ text: "No", style: "cancel" },
{
text: "Yes, Confirm All",
onPress: async () => {
setIsConfirmingAll(true);
let successCount = 0;
let errorCount = 0;
for (const booking of unconfirmedBookings) {
try {
const success = await new Promise<boolean>((resolve, _reject) => {
confirmBookingMutation(
{ uid: booking.uid },
{
onSuccess: () => {
resolve(true);
},
onError: () => {
resolve(false); // Continue even on error
},
}
);
});
if (success) {
successCount++;
} else {
errorCount++;
}
} catch {
errorCount++;
}
}
setIsConfirmingAll(false);
if (errorCount > 0) {
showInfoAlert(
"Partial Success",
`Confirmed ${successCount} bookings. Failed to confirm ${errorCount}.`
);
} else {
showSuccessAlert("Success", `All ${successCount} bookings have been confirmed.`);
}
},
},
]
);
},
[confirmBookingMutation]
);
// Reject all unconfirmed bookings in a recurring series
const handleRejectAll = React.useCallback(
async (group: RecurringBookingGroup) => {
const unconfirmedBookings = group.bookings.filter(
(b) =>
b.status?.toLowerCase() === "pending" ||
b.status?.toLowerCase() === "requires_confirmation" ||
b.requiresConfirmation
);
if (unconfirmedBookings.length === 0) {
showInfoAlert("Info", "No unconfirmed bookings to reject.");
return;
}
// For Android, open dialog with reason input
if (Platform.OS === "android") {
setRejectAllGroup(group);
setRejectAllReason("");
setShowRejectAllDialog(true);
return;
}
// For iOS, use Alert.prompt
Alert.alert(
"Reject All",
`Are you sure you want to reject ${unconfirmedBookings.length} unconfirmed bookings?`,
[
{ text: "No", style: "cancel" },
{
text: "Yes, Reject All",
style: "destructive",
onPress: () => {
Alert.prompt(
"Rejection Reason",
"Optionally provide a reason for rejecting (press OK to skip):",
[
{ text: "Cancel", style: "cancel" },
{
text: "OK",
onPress: async (reason?: string) => {
setIsDecliningAll(true);
let successCount = 0;
let errorCount = 0;
for (const booking of unconfirmedBookings) {
try {
const success = await new Promise<boolean>((resolve, _reject) => {
declineBookingMutation(
{
uid: booking.uid,
reason: reason || undefined,
},
{
onSuccess: () => {
resolve(true);
},
onError: () => {
resolve(false);
},
}
);
});
if (success) {
successCount++;
} else {
errorCount++;
}
} catch {
errorCount++;
}
}
setIsDecliningAll(false);
if (errorCount > 0) {
showInfoAlert(
"Partial Success",
`Rejected ${successCount} bookings. Failed to reject ${errorCount}.`
);
} else {
showSuccessAlert(
"Success",
`All ${successCount} bookings have been rejected.`
);
}
},
},
],
"plain-text",
"",
"default"
);
},
},
]
);
},
[declineBookingMutation]
);
const renderBookingItem = ({ item }: { item: Booking }) => {
return (
<BookingListItem
booking={item}
userEmail={userInfo?.email}
isConfirming={isConfirming}
isDeclining={isDeclining}
onPress={handleBookingPress}
onConfirm={handleInlineConfirm}
onReject={handleOpenRejectModal}
onReschedule={handleNavigateToReschedule}
onEditLocation={handleNavigateToEditLocation}
onAddGuests={handleNavigateToAddGuests}
onViewRecordings={handleNavigateToViewRecordings}
onMeetingSessionDetails={handleNavigateToMeetingSessionDetails}
onMarkNoShow={handleNavigateToMarkNoShow}
onReportBooking={() => {
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
}}
onCancelBooking={handleCancelBooking}
/>
);
};
const renderListItem = ({ item }: { item: ListItem }) => {
if (item.type === "monthHeader") {
return (
<View className="border-b border-[#E5E5EA] bg-[#f1f1f1] px-2 py-3 dark:border-[#4D4D4D] dark:bg-[#171717] md:px-4">
<Text className="text-base font-bold text-[#333] dark:text-white">{item.monthYear}</Text>
</View>
);
}
if (item.type === "recurringGroup") {
return (
<RecurringBookingListItem
group={item.group}
userEmail={userInfo?.email}
isConfirmingAll={isConfirmingAll}
isDecliningAll={isDecliningAll}
isCancellingAll={isCancellingAll}
onPress={handleRecurringGroupPress}
onConfirmAll={handleConfirmAll}
onRejectAll={handleRejectAll}
onCancelAllRemaining={handleCancelAllRemaining}
onReschedule={handleNavigateToReschedule}
onEditLocation={handleNavigateToEditLocation}
onAddGuests={handleNavigateToAddGuests}
onViewRecordings={handleNavigateToViewRecordings}
onMeetingSessionDetails={handleNavigateToMeetingSessionDetails}
onMarkNoShow={handleNavigateToMarkNoShow}
onReportBooking={() => {
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
}}
onCancelBooking={handleCancelBooking}
/>
);
}
return renderBookingItem({ item: item.booking });
};
if (loading) {
return (
<View className="flex-1 bg-gray-50 dark:bg-black">
{renderHeader?.()}
{renderFilterControls?.()}
<BookingListSkeleton count={4} iosStyle={iosStyle} />
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-gray-50 dark:bg-black">
{renderHeader?.()}
{renderFilterControls?.()}
<View className="flex-1 items-center justify-center bg-gray-50 p-5 dark:bg-black">
<Ionicons name="alert-circle" size={64} color={theme.destructive} />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800 dark:text-white">
Unable to load bookings
</Text>
<Text className="mb-6 text-center text-base text-gray-500 dark:text-[#A3A3A3]">
{error}
</Text>
</View>
</View>
);
}
// Determine what content to show
const showEmptyState = bookings.length === 0 && !loading;
const showSearchEmptyState =
filteredBookings.length === 0 && searchQuery.trim() !== "" && !loading && !showEmptyState;
const showList = !showEmptyState && !showSearchEmptyState && !loading;
const emptyState = getEmptyStateContent(activeFilter);
return (
<>
{renderHeader?.()}
{renderFilterControls?.()}
{/* Empty state - no bookings */}
{showEmptyState && (
<View className="flex-1 bg-gray-50 dark:bg-black">
<ScrollView
contentContainerStyle={{
padding: 20,
}}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: isDark ? "#000000" : "white" }}
>
<EmptyScreen
icon={emptyState.icon}
headline={emptyState.title}
description={emptyState.text}
/>
</ScrollView>
</View>
)}
{/* Search empty state */}
{showSearchEmptyState && (
<View className="flex-1 bg-gray-50 dark:bg-black">
<ScrollView
contentContainerStyle={{
padding: 20,
}}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: isDark ? "#000000" : "white" }}
>
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</ScrollView>
</View>
)}
{/* Bookings list */}
{showList &&
(isManualRefreshing ? (
<BookingListSkeleton count={4} iosStyle={iosStyle} />
) : iosStyle ? (
<FlatList
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={false} onRefresh={manualRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: isDark ? "#000000" : "white" }}
/>
) : (
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white dark:border-[#4D4D4D] dark:bg-black">
<FlatList
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={false} onRefresh={manualRefresh} />}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
))}
{/* Modals */}
<BookingModals
showRescheduleModal={false}
rescheduleBooking={null}
isRescheduling={isRescheduling}
onRescheduleClose={() => {}}
onRescheduleSubmit={async () => {}}
showRejectModal={showRejectModal}
rejectReason={rejectReason}
isDeclining={isDeclining}
onRejectClose={handleCloseRejectModal}
currentUserEmail={userInfo?.email}
onRejectSubmit={handleSubmitReject}
onRejectReasonChange={setRejectReason}
showFilterModal={showFilterModal}
eventTypes={eventTypes}
eventTypesLoading={eventTypesLoading}
selectedEventTypeId={selectedEventTypeId}
onFilterClose={() => setShowFilterModal?.(false)}
onEventTypeSelect={onEventTypeChange}
showBookingActionsModal={false}
selectedBooking={selectedBooking}
onActionsClose={() => {}}
onReschedule={() => {}}
onCancel={() => {}}
onEditLocation={() => {}}
onAddGuests={() => {}}
onViewRecordings={() => {}}
onMeetingSessionDetails={() => {}}
onMarkNoShow={() => {}}
/>
{/* Web/Extension: Cancel Event Modal */}
{Platform.OS === "web" && (
<FullScreenModal
visible={showCancelModal}
animationType="fade"
onRequestClose={handleCloseCancelModal}
>
<View className="flex-1 items-center justify-center bg-black/50 p-4">
<View className="w-full max-w-md rounded-2xl bg-white shadow-2xl dark:bg-[#171717]">
<View className="p-6">
<View className="flex-row">
{/* Danger icon */}
<View className="mr-3 self-start rounded-full bg-red-50 p-2 dark:bg-red-900/30">
<Ionicons name="alert-circle" size={20} color={theme.destructive} />
</View>
{/* Title and description */}
<View className="flex-1">
<Text className="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
Cancel Event
</Text>
<Text className="text-sm leading-5 text-gray-600 dark:text-[#A3A3A3]">
Are you sure you want to cancel "{cancelBooking?.title}"? Cancellation reason
will be shared with guests.
</Text>
{/* Reason Input */}
<View className="mt-4">
<Text className="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Reason for cancellation
</Text>
<TextInput
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-base text-gray-900 dark:border-[#4D4D4D] dark:bg-[#2C2C2E] dark:text-white"
placeholder="Why are you cancelling?"
placeholderTextColor={isDark ? "#A3A3A3" : "#9CA3AF"}
value={cancelReason}
onChangeText={setCancelReason}
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
</View>
</View>
</View>
{/* Buttons */}
<View className="flex-row-reverse gap-2 px-6 pb-6 pt-2">
<TouchableOpacity
className="rounded-lg px-4 py-2.5"
style={{ backgroundColor: isDark ? "#FFFFFF" : "#111827" }}
onPress={handleSubmitCancel}
>
<Text className="text-center text-base font-medium text-white dark:text-black">
Cancel Event
</Text>
</TouchableOpacity>
<TouchableOpacity
className="rounded-lg border border-gray-300 bg-white px-4 py-2.5 dark:border-[#4D4D4D] dark:bg-[#2C2C2E]"
onPress={handleCloseCancelModal}
>
<Text className="text-center text-base font-medium text-gray-700 dark:text-white">
Nevermind
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</FullScreenModal>
)}
{/* Cancel Event AlertDialog for Android */}
{Platform.OS === "android" && (
<AlertDialog open={showCancelModal} onOpenChange={handleCloseCancelModal}>
<AlertDialogContent>
<AlertDialogHeader className="items-start">
<AlertDialogTitle>
<UIText className="text-left text-lg font-semibold dark:text-white">
Cancel event
</UIText>
</AlertDialogTitle>
<AlertDialogDescription>
<UIText className="text-left text-sm text-muted-foreground">
Cancellation reason will be shared with guests
</UIText>
</AlertDialogDescription>
</AlertDialogHeader>
{/* Reason Input */}
<View>
<UIText className="mb-2 text-sm font-medium dark:text-white">
Reason for cancellation
</UIText>
<TextInput
className="rounded-md border border-gray-300 bg-white px-3 py-2.5 text-base text-gray-900 dark:border-[#4D4D4D] dark:bg-[#2C2C2E] dark:text-white"
placeholder="Why are you cancelling?"
placeholderTextColor={isDark ? "#A3A3A3" : "#9CA3AF"}
value={cancelReason}
onChangeText={setCancelReason}
autoFocus
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
<AlertDialogFooter>
<AlertDialogCancel onPress={handleCloseCancelModal}>
<UIText>Nevermind</UIText>
</AlertDialogCancel>
<AlertDialogAction onPress={handleSubmitCancel}>
<UIText className="text-white">Cancel event</UIText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Android: Cancel All Remaining Dialog */}
{Platform.OS === "android" && showCancelAllDialog && cancelAllGroup && (
<AlertDialog open={showCancelAllDialog} onOpenChange={setShowCancelAllDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel All Remaining</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel all {cancelAllGroup.remainingCount} remaining
bookings in this series?
</AlertDialogDescription>
</AlertDialogHeader>
<View className="py-4">
<Text className="mb-2 text-sm font-medium text-cal-text dark:text-white">
Cancellation Reason (required)
</Text>
<TextInput
className="rounded-lg border border-cal-border bg-cal-bg px-3 py-2 text-base text-cal-text dark:border-[#4D4D4D] dark:bg-[#2C2C2E] dark:text-white"
placeholder="Please provide a reason for cancelling"
placeholderTextColor={isDark ? "#A3A3A3" : "#9CA3AF"}
value={cancelAllReason}
onChangeText={setCancelAllReason}
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
<AlertDialogFooter>
<AlertDialogCancel
onPress={() => {
setShowCancelAllDialog(false);
setCancelAllGroup(null);
setCancelAllReason("");
}}
>
<UIText>Nevermind</UIText>
</AlertDialogCancel>
<AlertDialogAction
onPress={() => {
const reason = cancelAllReason.trim() || "Cancelled all remaining";
setShowCancelAllDialog(false);
setIsCancellingAll(true);
cancelBookingMutation(
{ uid: cancelAllGroup.recurringBookingUid, reason },
{
onSuccess: () => {
showSuccessAlert("Success", "All remaining bookings have been cancelled.");
setIsCancellingAll(false);
setCancelAllGroup(null);
setCancelAllReason("");
},
onError: () => {
showErrorAlert("Error", "Failed to cancel bookings. Please try again.");
setIsCancellingAll(false);
},
}
);
}}
>
<UIText className="text-white">Cancel All</UIText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Android: Reject All Dialog */}
{Platform.OS === "android" && showRejectAllDialog && rejectAllGroup && (
<AlertDialog open={showRejectAllDialog} onOpenChange={setShowRejectAllDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject All</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reject all unconfirmed bookings in this series?
</AlertDialogDescription>
</AlertDialogHeader>
<View className="py-4">
<Text className="mb-2 text-sm font-medium text-cal-text dark:text-white">
Rejection Reason (optional)
</Text>
<TextInput
className="rounded-lg border border-cal-border bg-cal-bg px-3 py-2 text-base text-cal-text dark:border-[#4D4D4D] dark:bg-[#2C2C2E] dark:text-white"
placeholder="Optionally provide a reason for rejecting"
placeholderTextColor={isDark ? "#A3A3A3" : "#9CA3AF"}
value={rejectAllReason}
onChangeText={setRejectAllReason}
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
<AlertDialogFooter>
<AlertDialogCancel
onPress={() => {
setShowRejectAllDialog(false);
setRejectAllGroup(null);
setRejectAllReason("");
}}
>
<UIText>Nevermind</UIText>
</AlertDialogCancel>
<AlertDialogAction
onPress={async () => {
const reason = rejectAllReason.trim() || undefined;
setShowRejectAllDialog(false);
setIsDecliningAll(true);
const unconfirmedBookings = rejectAllGroup.bookings.filter(
(b) =>
b.status?.toLowerCase() === "pending" ||
b.status?.toLowerCase() === "requires_confirmation" ||
b.requiresConfirmation
);
let successCount = 0;
let errorCount = 0;
for (const booking of unconfirmedBookings) {
try {
const success = await new Promise<boolean>((resolve, _reject) => {
declineBookingMutation(
{ uid: booking.uid, reason },
{
onSuccess: () => {
resolve(true);
},
onError: () => {
resolve(false);
},
}
);
});
if (success) {
successCount++;
} else {
errorCount++;
}
} catch {
errorCount++;
}
}
setIsDecliningAll(false);
setRejectAllGroup(null);
setRejectAllReason("");
if (errorCount > 0) {
showInfoAlert(
"Partial Success",
`Rejected ${successCount} bookings. Failed to reject ${errorCount}.`
);
} else {
showSuccessAlert("Success", `All ${successCount} bookings have been rejected.`);
}
}}
>
<UIText className="text-white">Reject All</UIText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
};