feat(companion): booking actions ios ui update (#26307)
* feat(companion): add native iOS UI components for modals and pickers - Add LogoutConfirmModal.ios.tsx using native Alert.alert() - Add BookingModals.ios.tsx using native Alert.prompt() for reject modal - Add RescheduleScreen.ios.tsx using @expo/ui/swift-ui DateTimePicker These iOS-specific implementations provide native glass UI experience instead of custom React Native modals. * fix(companion): use correct DatePicker API from @expo/ui/swift-ui - Change import from DateTimePicker to DatePicker - Use onDateChange instead of onDateSelected - Use displayedComponents as array instead of string - Use selection instead of initialDate * feat(companion): add native Android DateTimePicker for RescheduleScreen - Use @expo/ui/jetpack-compose DateTimePicker for native Android date/time selection - Use variant='picker' with showVariantToggle=false for consistent UI - Use displayedComponents='date' for date picker and 'hourAndMinute' for time picker - Use is24Hour=true for 24-hour time format * Revert "feat(companion): add native Android DateTimePicker for RescheduleScreen" This reverts commit 95677679c2e86889b935e658fac011ae9669fa10. * adjust date and time picker for ios, and use native ios ui for delete event type * feat(companion): add native Android DateTimePicker and web HTML inputs for RescheduleScreen - Add RescheduleScreen.android.tsx using @react-native-community/datetimepicker for native Android date/time pickers - Update RescheduleScreen.tsx to use native HTML date/time inputs on web/extension - Install @react-native-community/datetimepicker package * fix lint errors * use expos date and time picker for android * address cubics comments * remove dependancy causing android build fail * fix versions * custom date and time picker for android * fix react compiler memo issue * address cubics commments * feat(companion): convert booking action pages to native iOS bottom sheets (#26366) * feat(companion): add native iOS bottom sheet for meeting session details * move file to correct location, remove bg and use glass ui bg * feat(companion): convert booking action pages to iOS bottom sheets - Add iOS-specific bottom sheet implementations for: - edit-location (detents: 0.5, 0.7) - mark-no-show (detents: 0.4, 0.6) - view-recordings (detents: 0.7, 0.9) - add-guests (detents: 0.6, 0.8) - reschedule (detents: 0.8, 0.95) - Move route files from (tabs)/(bookings)/ to root app/ level - Register screens in root _layout.tsx with platform-specific options - Update navigation paths from nested to root level - Remove migrated screens from bookings _layout.tsx * updated booking actions pages to use glass ui bottom sheet * new ui for booking actions ready * add biome file * fix event type detail react compiler issue * update button style * fix black flash ui * use glass ui buttons for Availability and event type detail page * fix lint issue * address cubics comments * make booking actions ui transparent * remove black flash and keep transparent ui --------- Co-authored-by: Volnei Munhoz <volnei@cal.com>
This commit is contained in:
co-authored by
Volnei Munhoz
parent
bbf9274d37
commit
6339f77efc
@@ -6,12 +6,6 @@ export default function BookingsLayout() {
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: Platform.OS === "ios" }} />
|
||||
<Stack.Screen name="booking-detail" />
|
||||
<Stack.Screen name="reschedule" />
|
||||
<Stack.Screen name="edit-location" />
|
||||
<Stack.Screen name="add-guests" />
|
||||
<Stack.Screen name="mark-no-show" />
|
||||
<Stack.Screen name="view-recordings" />
|
||||
<Stack.Screen name="meeting-session-details" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "react-native";
|
||||
import { EmptyScreen } from "@/components/EmptyScreen";
|
||||
import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import { LoadingSpinner } from "@/components/LoadingSpinner";
|
||||
import {
|
||||
useCreateEventType,
|
||||
@@ -57,7 +56,7 @@ export default function EventTypesIOS() {
|
||||
const refreshing = isFetching && !loading;
|
||||
|
||||
const { mutate: createEventTypeMutation } = useCreateEventType();
|
||||
const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType();
|
||||
const { mutate: deleteEventTypeMutation } = useDeleteEventType();
|
||||
const { mutate: duplicateEventTypeMutation } = useDuplicateEventType();
|
||||
|
||||
// Convert query error to string
|
||||
@@ -69,9 +68,7 @@ export default function EventTypesIOS() {
|
||||
queryError?.message?.includes("401");
|
||||
const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null;
|
||||
|
||||
// Modal state for delete confirmation
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [eventTypeToDelete, setEventTypeToDelete] = useState<EventType | null>(null);
|
||||
// No modal state needed for iOS - using native Alert for delete confirmation
|
||||
|
||||
// Handle pull-to-refresh (offline-aware)
|
||||
const onRefresh = () => offlineAwareRefresh(refetch);
|
||||
@@ -164,33 +161,35 @@ export default function EventTypesIOS() {
|
||||
};
|
||||
|
||||
const handleDelete = (eventType: EventType) => {
|
||||
setEventTypeToDelete(eventType);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!eventTypeToDelete) return;
|
||||
|
||||
deleteEventTypeMutation(eventTypeToDelete.id, {
|
||||
onSuccess: () => {
|
||||
// Close modal and reset state
|
||||
setShowDeleteModal(false);
|
||||
setEventTypeToDelete(null);
|
||||
Alert.alert("Success", "Event type deleted successfully");
|
||||
},
|
||||
onError: (deleteError) => {
|
||||
const message = deleteError instanceof Error ? deleteError.message : String(deleteError);
|
||||
console.error("Failed to delete event type", message);
|
||||
if (__DEV__) {
|
||||
const stack = deleteError instanceof Error ? deleteError.stack : undefined;
|
||||
console.debug("[EventTypes] deleteEventType failed", {
|
||||
message,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
showErrorAlert("Error", "Failed to delete event type. Please try again.");
|
||||
},
|
||||
});
|
||||
// Use native iOS Alert for confirmation
|
||||
Alert.alert(
|
||||
"Delete Event Type",
|
||||
`Are you sure you want to delete "${eventType.title}"? This action cannot be undone.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteEventTypeMutation(eventType.id, {
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Event type deleted successfully");
|
||||
},
|
||||
onError: (deleteError) => {
|
||||
const message =
|
||||
deleteError instanceof Error ? deleteError.message : String(deleteError);
|
||||
console.error("Failed to delete event type", message);
|
||||
if (__DEV__) {
|
||||
const stack = deleteError instanceof Error ? deleteError.stack : undefined;
|
||||
console.debug("[EventTypes] deleteEventType failed", { message, stack });
|
||||
}
|
||||
showErrorAlert("Error", "Failed to delete event type. Please try again.");
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = (eventType: EventType) => {
|
||||
@@ -500,64 +499,6 @@ export default function EventTypesIOS() {
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<FullScreenModal
|
||||
visible={showDeleteModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => {
|
||||
if (!isDeleting) {
|
||||
setShowDeleteModal(false);
|
||||
setEventTypeToDelete(null);
|
||||
}
|
||||
}}>
|
||||
<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">
|
||||
{/* Header with icon and title */}
|
||||
<View className="p-6">
|
||||
<View className="flex-row">
|
||||
{/* Danger icon */}
|
||||
<View className="mr-3 self-start rounded-full bg-red-50 p-2">
|
||||
<Ionicons name="alert-circle" size={20} color="#800000" />
|
||||
</View>
|
||||
|
||||
{/* Title and description */}
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-900">Delete Event Type</Text>
|
||||
<Text className="text-sm leading-5 text-gray-600">
|
||||
{eventTypeToDelete ? (
|
||||
<>
|
||||
This will permanently delete the "{eventTypeToDelete.title}" event type. This action
|
||||
cannot be undone.
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer with buttons */}
|
||||
<View className="flex-row-reverse gap-2 px-6 pb-6 pt-2">
|
||||
<TouchableOpacity
|
||||
className={`rounded-lg bg-gray-900 px-4 py-2.5 ${isDeleting ? "opacity-50" : ""}`}
|
||||
onPress={confirmDelete}
|
||||
disabled={isDeleting}>
|
||||
<Text className="text-center text-base font-medium text-white">Delete</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2.5"
|
||||
onPress={() => {
|
||||
setShowDeleteModal(false);
|
||||
setEventTypeToDelete(null);
|
||||
}}
|
||||
disabled={isDeleting}>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+130
-9
@@ -21,18 +21,13 @@ function RootLayoutContent() {
|
||||
headerLargeTitle: false,
|
||||
title: Platform.OS === "ios" ? "You" : "Profile",
|
||||
presentation:
|
||||
Platform.OS === "ios"
|
||||
? isLiquidGlassAvailable()
|
||||
? "formSheet"
|
||||
: "modal"
|
||||
: "containedModal",
|
||||
Platform.OS === "ios" ? (isLiquidGlassAvailable() ? "formSheet" : "modal") : "containedModal",
|
||||
// iOS-specific sheet options (ignored on Android)
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.6, 0.9] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#FFFFFF",
|
||||
backgroundColor: Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#FFFFFF",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#FFFFFF",
|
||||
@@ -40,6 +35,133 @@ function RootLayoutContent() {
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="meeting-session-details"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Session Details",
|
||||
presentation:
|
||||
Platform.OS === "ios" ? (isLiquidGlassAvailable() ? "formSheet" : "modal") : "containedModal",
|
||||
// iOS-specific sheet options (ignored on Android)
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 0.9] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor: Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="edit-location"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Edit Location",
|
||||
presentation:
|
||||
Platform.OS === "ios" ? (isLiquidGlassAvailable() ? "formSheet" : "modal") : "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.5, 0.7] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor: Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="mark-no-show"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Mark No-Show",
|
||||
presentation:
|
||||
Platform.OS === "ios" ? (isLiquidGlassAvailable() ? "formSheet" : "modal") : "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.4, 0.6] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor: Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="view-recordings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Recordings",
|
||||
presentation:
|
||||
Platform.OS === "ios" ? (isLiquidGlassAvailable() ? "formSheet" : "modal") : "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 0.9] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor: Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="add-guests"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Add Guests",
|
||||
presentation:
|
||||
Platform.OS === "ios" ? (isLiquidGlassAvailable() ? "formSheet" : "modal") : "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.6, 0.8] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor: Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="reschedule"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Reschedule",
|
||||
presentation:
|
||||
Platform.OS === "ios" ? (isLiquidGlassAvailable() ? "formSheet" : "modal") : "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.8, 0.95] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor: Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<LoginScreenComponent />
|
||||
@@ -63,8 +185,7 @@ function RootLayoutContent() {
|
||||
return (
|
||||
<View
|
||||
style={[containerStyle, Platform.OS === "web" && { pointerEvents: "auto" as const }]}
|
||||
className={containerClass}
|
||||
>
|
||||
className={containerClass}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
|
||||
{content}
|
||||
<NetworkStatusBanner />
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen";
|
||||
import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen";
|
||||
import { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
|
||||
// Semi-transparent background to prevent black flash while preserving glass effect
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function AddGuestsIOS() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [guestCount, setGuestCount] = useState(0);
|
||||
|
||||
const addGuestsScreenRef = useRef<AddGuestsScreenHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
addGuestsScreenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleAddGuestsSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
const showSaveButton = guestCount > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Add Guests",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Add Guests</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
{showSaveButton ? (
|
||||
<Stack.Header.Button
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
variant="prominent"
|
||||
tintColor="#000"
|
||||
>
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
) : null}
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<AddGuestsScreenComponent
|
||||
ref={addGuestsScreenRef}
|
||||
booking={booking}
|
||||
onSuccess={handleAddGuestsSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
onGuestCountChange={setGuestCount}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen";
|
||||
import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen";
|
||||
@@ -40,14 +41,23 @@ export default function AddGuests() {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const renderHeaderLeft = useCallback(
|
||||
() => (
|
||||
<AppPressable onPress={() => router.back()} className="px-2 py-2">
|
||||
<Ionicons name="close" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[router]
|
||||
);
|
||||
|
||||
const renderHeaderRight = useCallback(
|
||||
() => (
|
||||
<AppPressable
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
className={`px-4 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className="text-[16px] font-semibold text-[#007AFF]">Save</Text>
|
||||
<Ionicons name="checkmark" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[handleSave, isSaving]
|
||||
@@ -63,9 +73,8 @@ export default function AddGuests() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Title>Add Guests</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -83,17 +92,24 @@ export default function AddGuests() {
|
||||
options={{
|
||||
title: "Add Guests",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
|
||||
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Add Guests</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
|
||||
Save
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
@@ -1,17 +1,47 @@
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { AvailabilityDetailScreen } from "@/components/screens/AvailabilityDetailScreen";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
AvailabilityDetailScreen,
|
||||
type AvailabilityDetailScreenHandle,
|
||||
} from "@/components/screens/AvailabilityDetailScreen";
|
||||
|
||||
export default function AvailabilityDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const screenRef = useRef<AvailabilityDetailScreenHandle>(null);
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
screenRef.current?.save();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<AvailabilityDetailScreen id={id} />
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="chevron.left" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Edit Availability</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button onPress={handleSave} variant="prominent" tintColor="#000">
|
||||
Save
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<AvailabilityDetailScreen ref={screenRef} id={id} useNativeHeader={Platform.OS === "ios"} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen";
|
||||
import EditLocationScreenComponent from "@/components/screens/EditLocationScreen";
|
||||
import { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
|
||||
// Semi-transparent background to prevent black flash while preserving glass effect
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function EditLocationIOS() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const editLocationScreenRef = useRef<EditLocationScreenHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
editLocationScreenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleUpdateSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Edit Location",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Edit Location</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
variant="prominent"
|
||||
tintColor="#000"
|
||||
>
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<EditLocationScreenComponent
|
||||
ref={editLocationScreenRef}
|
||||
booking={booking}
|
||||
onSuccess={handleUpdateSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+23
-7
@@ -1,6 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen";
|
||||
import EditLocationScreenComponent from "@/components/screens/EditLocationScreen";
|
||||
@@ -40,14 +41,23 @@ export default function EditLocation() {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const renderHeaderLeft = useCallback(
|
||||
() => (
|
||||
<AppPressable onPress={() => router.back()} className="px-2 py-2">
|
||||
<Ionicons name="close" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[router]
|
||||
);
|
||||
|
||||
const renderHeaderRight = useCallback(
|
||||
() => (
|
||||
<AppPressable
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
className={`px-4 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className="text-[16px] font-semibold text-[#007AFF]">Save</Text>
|
||||
<Ionicons name="checkmark" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[handleSave, isSaving]
|
||||
@@ -63,9 +73,8 @@ export default function EditLocation() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Title>Edit Location</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -83,17 +92,24 @@ export default function EditLocation() {
|
||||
options={{
|
||||
title: "Edit Location",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
|
||||
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Edit Location</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
|
||||
Save
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
+584
-544
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
|
||||
import { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
|
||||
interface Attendee {
|
||||
id?: number | string;
|
||||
email: string;
|
||||
name: string;
|
||||
noShow?: boolean;
|
||||
}
|
||||
|
||||
interface BookingAttendee {
|
||||
id?: number | string;
|
||||
email: string;
|
||||
name?: string;
|
||||
noShow?: boolean;
|
||||
absent?: boolean;
|
||||
}
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function MarkNoShowIOS() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then((bookingData) => {
|
||||
setBooking(bookingData);
|
||||
const bookingAttendees: Attendee[] = [];
|
||||
if (bookingData.attendees && Array.isArray(bookingData.attendees)) {
|
||||
bookingData.attendees.forEach((att: BookingAttendee) => {
|
||||
bookingAttendees.push({
|
||||
id: att.id,
|
||||
email: att.email,
|
||||
name: att.name || att.email,
|
||||
noShow: att.absent === true || att.noShow === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
setAttendees(bookingAttendees);
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Mark No-Show",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Mark No-Show</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: useGlassEffect ? "transparent" : "#F2F2F7",
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<MarkNoShowScreenComponent
|
||||
booking={booking}
|
||||
attendees={attendees}
|
||||
onUpdate={setAttendees}
|
||||
onBookingUpdate={(updatedBooking) => {
|
||||
setBooking(updatedBooking);
|
||||
const updatedAttendees: Attendee[] = [];
|
||||
if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
|
||||
updatedBooking.attendees.forEach((att: BookingAttendee) => {
|
||||
updatedAttendees.push({
|
||||
id: att.id,
|
||||
email: att.email,
|
||||
name: att.name || att.email,
|
||||
noShow: att.absent === true || att.noShow === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
setAttendees(updatedAttendees);
|
||||
}}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
|
||||
import { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
|
||||
@@ -58,6 +60,15 @@ export default function MarkNoShow() {
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const renderHeaderLeft = useCallback(
|
||||
() => (
|
||||
<AppPressable onPress={() => router.back()} className="px-2 py-2">
|
||||
<Ionicons name="close" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[router]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -68,9 +79,8 @@ export default function MarkNoShow() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Title>Mark No-Show</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -88,12 +98,18 @@ export default function MarkNoShow() {
|
||||
options={{
|
||||
title: "Mark No-Show",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header with native styling */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Mark No-Show</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import type { ConferencingSession } from "@/services/types/bookings.types";
|
||||
|
||||
/**
|
||||
* Get the presentation style for the meeting session details sheet
|
||||
* - Uses formSheet on iPhone with liquid glass support
|
||||
* - Uses modal on iPad or older iOS devices
|
||||
*/
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function MeetingSessionDetailsIOS() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [sessions, setSessions] = useState<ConferencingSession[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getConferencingSessions(uid)
|
||||
.then((sessionsData) => {
|
||||
setSessions(sessionsData);
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load session details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Session Details",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Session Details</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: useGlassEffect ? "transparent" : "#F2F2F7",
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<MeetingSessionDetailsScreenComponent
|
||||
sessions={sessions}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+21
-5
@@ -1,6 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import type { ConferencingSession } from "@/services/types/bookings.types";
|
||||
@@ -30,6 +32,15 @@ export default function MeetingSessionDetails() {
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const renderHeaderLeft = useCallback(
|
||||
() => (
|
||||
<AppPressable onPress={() => router.back()} className="px-2 py-2">
|
||||
<Ionicons name="close" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[router]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -40,9 +51,8 @@ export default function MeetingSessionDetails() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Title>Session Details</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -60,12 +70,18 @@ export default function MeetingSessionDetails() {
|
||||
options={{
|
||||
title: "Session Details",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header with native styling */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Session Details</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen";
|
||||
import RescheduleScreenComponent from "@/components/screens/RescheduleScreen";
|
||||
import { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
|
||||
// Semi-transparent background to prevent black flash while preserving glass effect
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function RescheduleIOS() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const rescheduleScreenRef = useRef<RescheduleScreenHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
rescheduleScreenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleRescheduleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Reschedule",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Reschedule</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
variant="prominent"
|
||||
tintColor="#000"
|
||||
>
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<RescheduleScreenComponent
|
||||
ref={rescheduleScreenRef}
|
||||
booking={booking}
|
||||
onSuccess={handleRescheduleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen";
|
||||
import RescheduleScreenComponent from "@/components/screens/RescheduleScreen";
|
||||
@@ -40,14 +41,23 @@ export default function Reschedule() {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const renderHeaderLeft = useCallback(
|
||||
() => (
|
||||
<AppPressable onPress={() => router.back()} className="px-2 py-2">
|
||||
<Ionicons name="close" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[router]
|
||||
);
|
||||
|
||||
const renderHeaderRight = useCallback(
|
||||
() => (
|
||||
<AppPressable
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
className={`px-4 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className="text-[16px] font-semibold text-[#007AFF]">Save</Text>
|
||||
<Ionicons name="checkmark" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[handleSave, isSaving]
|
||||
@@ -64,7 +74,7 @@ export default function Reschedule() {
|
||||
/>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Title>Reschedule</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -82,17 +92,24 @@ export default function Reschedule() {
|
||||
options={{
|
||||
title: "Reschedule",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
|
||||
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Reschedule</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
|
||||
Save
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
@@ -103,6 +120,7 @@ export default function Reschedule() {
|
||||
booking={booking}
|
||||
onSuccess={handleRescheduleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
useNativeHeader
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import type { BookingRecording } from "@/services/types/bookings.types";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function ViewRecordingsIOS() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [recordings, setRecordings] = useState<BookingRecording[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getRecordings(uid)
|
||||
.then(setRecordings)
|
||||
.catch((error) => {
|
||||
safeLogError("Failed to load recordings:", error);
|
||||
Alert.alert("Error", "Failed to load recordings");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Recordings",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Recordings</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: useGlassEffect ? "transparent" : "#F2F2F7",
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<ViewRecordingsScreenComponent
|
||||
recordings={recordings}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+21
-5
@@ -1,6 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import type { BookingRecording } from "@/services/types/bookings.types";
|
||||
@@ -30,6 +32,15 @@ export default function ViewRecordings() {
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const renderHeaderLeft = useCallback(
|
||||
() => (
|
||||
<AppPressable onPress={() => router.back()} className="px-2 py-2">
|
||||
<Ionicons name="close" size={24} color="#007AFF" />
|
||||
</AppPressable>
|
||||
),
|
||||
[router]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -40,9 +51,8 @@ export default function ViewRecordings() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Title>Recordings</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -60,12 +70,18 @@ export default function ViewRecordings() {
|
||||
options={{
|
||||
title: "Recordings",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header with native styling */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Recordings</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
||||
"root": false,
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.json",
|
||||
"!**/node_modules",
|
||||
"!**/.expo",
|
||||
"!**/dist",
|
||||
"!**/.output",
|
||||
"!**/*.d.ts",
|
||||
"!**/bun.lock",
|
||||
"!**/android",
|
||||
"!**/ios"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error",
|
||||
"noArrayIndexKey": "error",
|
||||
"useIterableCallbackReturn": "error",
|
||||
"noImplicitAnyLet": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "error",
|
||||
"useHookAtTopLevel": "error",
|
||||
"noInvalidUseBeforeDeclaration": "off",
|
||||
"noUnusedFunctionParameters": "error",
|
||||
"noUnusedVariables": "error"
|
||||
},
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "error"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "error"
|
||||
},
|
||||
"a11y": {
|
||||
"noStaticElementInteractions": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "es5",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["extension/**/*.ts", "extension/**/*.tsx"],
|
||||
"javascript": {
|
||||
"globals": ["chrome", "browser"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
interface LogoutConfirmModalProps {
|
||||
visible: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function LogoutConfirmModal({ visible, onConfirm, onCancel }: LogoutConfirmModalProps) {
|
||||
// Track if alert has been shown for current visible=true state to prevent re-triggering
|
||||
// when parent passes unstable callback references
|
||||
const alertShownRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !alertShownRef.current) {
|
||||
alertShownRef.current = true;
|
||||
Alert.alert(
|
||||
"Sign Out",
|
||||
"Are you sure you want to sign out?",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
onPress: onCancel,
|
||||
},
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: onConfirm,
|
||||
},
|
||||
],
|
||||
{ cancelable: true, onDismiss: onCancel }
|
||||
);
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
alertShownRef.current = false;
|
||||
}
|
||||
}, [visible, onConfirm, onCancel]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
const handleNavigateToReschedule = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/reschedule",
|
||||
pathname: "/reschedule",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
@@ -134,7 +134,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
const handleNavigateToEditLocation = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/edit-location",
|
||||
pathname: "/edit-location",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
@@ -145,7 +145,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
const handleNavigateToAddGuests = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/add-guests",
|
||||
pathname: "/add-guests",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
@@ -156,7 +156,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
const handleNavigateToMarkNoShow = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/mark-no-show",
|
||||
pathname: "/mark-no-show",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
@@ -167,7 +167,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
const handleNavigateToViewRecordings = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/view-recordings",
|
||||
pathname: "/view-recordings",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
@@ -178,7 +178,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
const handleNavigateToMeetingSessionDetails = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/meeting-session-details",
|
||||
pathname: "/meeting-session-details",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
@@ -194,15 +194,11 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
case "upcoming":
|
||||
case "unconfirmed":
|
||||
// Sort by start time ascending
|
||||
return sorted.sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
||||
);
|
||||
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()
|
||||
);
|
||||
return sorted.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
||||
default:
|
||||
return sorted;
|
||||
}
|
||||
@@ -331,17 +327,10 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
|
||||
}
|
||||
refreshControl={<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
style={{ backgroundColor: "white" }}
|
||||
>
|
||||
<EmptyScreen
|
||||
icon={emptyState.icon}
|
||||
headline={emptyState.title}
|
||||
description={emptyState.text}
|
||||
/>
|
||||
style={{ backgroundColor: "white" }}>
|
||||
<EmptyScreen icon={emptyState.icon} headline={emptyState.title} description={emptyState.text} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Activity>
|
||||
@@ -353,12 +342,9 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
|
||||
}
|
||||
refreshControl={<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
style={{ backgroundColor: "white" }}
|
||||
>
|
||||
style={{ backgroundColor: "white" }}>
|
||||
<EmptyScreen
|
||||
icon="search-outline"
|
||||
headline={`No results found for "${searchQuery}"`}
|
||||
@@ -376,9 +362,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
keyExtractor={(item) => item.key}
|
||||
renderItem={renderListItem}
|
||||
contentContainerStyle={{ paddingBottom: 90 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
|
||||
}
|
||||
refreshControl={<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
style={{ backgroundColor: "white" }}
|
||||
@@ -393,9 +377,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
keyExtractor={(item) => item.key}
|
||||
renderItem={renderListItem}
|
||||
contentContainerStyle={{ paddingBottom: 90 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
|
||||
}
|
||||
refreshControl={<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { ActivityIndicator, Alert, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||
import { BookingActionsModal } from "@/components/BookingActionsModal";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import type { Booking, EventType } from "@/services/calcom";
|
||||
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
|
||||
|
||||
const EMPTY_ACTIONS: BookingActionsResult = {
|
||||
reschedule: { visible: false, enabled: false },
|
||||
rescheduleRequest: { visible: false, enabled: false },
|
||||
cancel: { visible: false, enabled: false },
|
||||
changeLocation: { visible: false, enabled: false },
|
||||
addGuests: { visible: false, enabled: false },
|
||||
viewRecordings: { visible: false, enabled: false },
|
||||
meetingSessionDetails: { visible: false, enabled: false },
|
||||
markNoShow: { visible: false, enabled: false },
|
||||
};
|
||||
|
||||
interface BookingModalsProps {
|
||||
showRescheduleModal: boolean;
|
||||
rescheduleBooking: Booking | null;
|
||||
isRescheduling: boolean;
|
||||
onRescheduleClose: () => void;
|
||||
onRescheduleSubmit: (date: string, time: string, reason?: string) => Promise<void>;
|
||||
|
||||
showRejectModal: boolean;
|
||||
rejectReason: string;
|
||||
isDeclining: boolean;
|
||||
onRejectClose: () => void;
|
||||
onRejectSubmit: (reason?: string) => void;
|
||||
onRejectReasonChange: (reason: string) => void;
|
||||
|
||||
showFilterModal?: boolean;
|
||||
eventTypes?: EventType[];
|
||||
eventTypesLoading?: boolean;
|
||||
selectedEventTypeId?: number | null;
|
||||
onFilterClose?: () => void;
|
||||
onEventTypeSelect?: (id: number | null, label?: string | null) => void;
|
||||
|
||||
showBookingActionsModal: boolean;
|
||||
selectedBooking: Booking | null;
|
||||
onActionsClose: () => void;
|
||||
onReschedule: () => void;
|
||||
onCancel: () => void;
|
||||
|
||||
currentUserEmail?: string;
|
||||
|
||||
onEditLocation?: (booking: Booking) => void;
|
||||
onAddGuests?: (booking: Booking) => void;
|
||||
onViewRecordings?: (booking: Booking) => void;
|
||||
onMeetingSessionDetails?: (booking: Booking) => void;
|
||||
onMarkNoShow?: (booking: Booking) => void;
|
||||
}
|
||||
|
||||
export const BookingModals: React.FC<BookingModalsProps> = ({
|
||||
showRescheduleModal: _showRescheduleModal,
|
||||
rescheduleBooking: _rescheduleBooking,
|
||||
isRescheduling: _isRescheduling,
|
||||
onRescheduleClose: _onRescheduleClose,
|
||||
onRescheduleSubmit: _onRescheduleSubmit,
|
||||
showRejectModal,
|
||||
rejectReason,
|
||||
isDeclining,
|
||||
onRejectClose,
|
||||
onRejectSubmit,
|
||||
|
||||
showFilterModal,
|
||||
eventTypes,
|
||||
eventTypesLoading,
|
||||
selectedEventTypeId,
|
||||
onFilterClose,
|
||||
onEventTypeSelect,
|
||||
showBookingActionsModal,
|
||||
selectedBooking,
|
||||
onActionsClose,
|
||||
onReschedule,
|
||||
onCancel,
|
||||
currentUserEmail,
|
||||
onEditLocation,
|
||||
onAddGuests,
|
||||
onViewRecordings,
|
||||
onMeetingSessionDetails,
|
||||
onMarkNoShow,
|
||||
}) => {
|
||||
const actions = useMemo(() => {
|
||||
if (!selectedBooking) return EMPTY_ACTIONS;
|
||||
return getBookingActions({
|
||||
booking: selectedBooking,
|
||||
eventType: undefined,
|
||||
currentUserId: undefined,
|
||||
currentUserEmail: currentUserEmail,
|
||||
isOnline: true,
|
||||
});
|
||||
}, [selectedBooking, currentUserEmail]);
|
||||
|
||||
const hasShownRejectAlert = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showRejectModal && !isDeclining && !hasShownRejectAlert.current) {
|
||||
hasShownRejectAlert.current = true;
|
||||
Alert.prompt(
|
||||
"Reject the booking request?",
|
||||
"Are you sure you want to reject the booking? We'll let the person who tried to book know. You can provide a reason below.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
onPress: () => {
|
||||
hasShownRejectAlert.current = false;
|
||||
onRejectClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Reject",
|
||||
style: "destructive",
|
||||
onPress: (reason?: string) => {
|
||||
hasShownRejectAlert.current = false;
|
||||
// Pass reason directly to avoid race condition with state updates
|
||||
onRejectSubmit(reason);
|
||||
},
|
||||
},
|
||||
],
|
||||
"plain-text",
|
||||
rejectReason,
|
||||
"default"
|
||||
);
|
||||
} else if (!showRejectModal) {
|
||||
hasShownRejectAlert.current = false;
|
||||
}
|
||||
}, [showRejectModal, isDeclining, onRejectClose, onRejectSubmit, rejectReason]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilterModal !== undefined && onFilterClose && onEventTypeSelect ? (
|
||||
<FullScreenModal
|
||||
visible={showFilterModal}
|
||||
animationType="fade"
|
||||
onRequestClose={onFilterClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-[rgba(0,0,0,0.5)]"
|
||||
activeOpacity={1}
|
||||
onPress={onFilterClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
className="w-[85%] max-w-[350px] rounded-2xl bg-white p-5"
|
||||
>
|
||||
<Text className="mb-4 text-center text-lg font-semibold text-[#333]">
|
||||
Filter by Event Type
|
||||
</Text>
|
||||
|
||||
{eventTypesLoading ? (
|
||||
<View className="items-center py-4">
|
||||
<ActivityIndicator size="small" color="#333" />
|
||||
<Text className="mt-2 text-sm text-[#666]">Loading event types...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView showsVerticalScrollIndicator={true} style={{ maxHeight: 400 }}>
|
||||
{eventTypes?.map((eventType) => (
|
||||
<TouchableOpacity
|
||||
key={eventType.id}
|
||||
className={`mb-1 flex-row items-center justify-between rounded-lg px-4 py-3 ${
|
||||
selectedEventTypeId === eventType.id ? "bg-[#F0F0F0]" : ""
|
||||
}`}
|
||||
onPress={() => onEventTypeSelect(eventType.id, eventType.title)}
|
||||
>
|
||||
<Text
|
||||
className={`text-base text-[#333] ${
|
||||
selectedEventTypeId === eventType.id ? "font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{eventType.title}
|
||||
</Text>
|
||||
{selectedEventTypeId === eventType.id ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
{eventTypes?.length === 0 ? (
|
||||
<View className="items-center py-4">
|
||||
<Text className="text-sm text-[#666]">No event types found</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
) : null}
|
||||
|
||||
<BookingActionsModal
|
||||
visible={showBookingActionsModal}
|
||||
onClose={onActionsClose}
|
||||
booking={selectedBooking}
|
||||
actions={actions}
|
||||
onReschedule={onReschedule}
|
||||
onEditLocation={() => {
|
||||
if (selectedBooking && onEditLocation) {
|
||||
onEditLocation(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onAddGuests={() => {
|
||||
if (selectedBooking && onAddGuests) {
|
||||
onAddGuests(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onViewRecordings={() => {
|
||||
if (selectedBooking && onViewRecordings) {
|
||||
onViewRecordings(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onMeetingSessionDetails={() => {
|
||||
if (selectedBooking && onMeetingSessionDetails) {
|
||||
onMeetingSessionDetails(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onMarkNoShow={() => {
|
||||
if (selectedBooking && onMarkNoShow) {
|
||||
onMarkNoShow(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
}}
|
||||
onCancelBooking={onCancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -40,7 +40,7 @@ interface BookingModalsProps {
|
||||
rejectReason: string;
|
||||
isDeclining: boolean;
|
||||
onRejectClose: () => void;
|
||||
onRejectSubmit: () => void;
|
||||
onRejectSubmit: (reason?: string) => void;
|
||||
onRejectReasonChange: (reason: string) => void;
|
||||
|
||||
// Filter modal props (optional for iOS)
|
||||
@@ -150,7 +150,9 @@ export const BookingModals: React.FC<BookingModalsProps> = ({
|
||||
onPress={() => onEventTypeSelect(eventType.id, eventType.title)}
|
||||
>
|
||||
<Text
|
||||
className={`text-base text-[#333] ${selectedEventTypeId === eventType.id ? "font-semibold" : ""}`}
|
||||
className={`text-base text-[#333] ${
|
||||
selectedEventTypeId === eventType.id ? "font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{eventType.title}
|
||||
</Text>
|
||||
@@ -272,7 +274,7 @@ export const BookingModals: React.FC<BookingModalsProps> = ({
|
||||
{/* Reject Button */}
|
||||
<TouchableOpacity
|
||||
className="rounded-md bg-gray-900 px-4 py-2"
|
||||
onPress={onRejectSubmit}
|
||||
onPress={() => onRejectSubmit()}
|
||||
disabled={isDeclining}
|
||||
style={{ opacity: isDeclining ? 0.5 : 1 }}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface AddGuestsScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
onGuestCountChange?: (count: number) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
// Handle type for parent component to call submit
|
||||
@@ -39,8 +41,13 @@ function isValidEmail(email: string): boolean {
|
||||
}
|
||||
|
||||
export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreenProps>(
|
||||
function AddGuestsScreen({ booking, onSuccess, onSavingChange }, ref) {
|
||||
function AddGuestsScreen(
|
||||
{ booking, onSuccess, onSavingChange, onGuestCountChange, transparentBackground = false },
|
||||
ref
|
||||
) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]";
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [guests, setGuests] = useState<{ email: string; name?: string }[]>([]);
|
||||
@@ -51,6 +58,11 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
// Notify parent of guest count changes
|
||||
useEffect(() => {
|
||||
onGuestCountChange?.(guests.length);
|
||||
}, [guests.length, onGuestCountChange]);
|
||||
|
||||
const handleAddGuest = useCallback(() => {
|
||||
const trimmedEmail = email.trim();
|
||||
if (!trimmedEmail) {
|
||||
@@ -111,7 +123,7 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -120,62 +132,70 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1 bg-[#F2F2F7]"
|
||||
>
|
||||
className={`flex-1 ${backgroundStyle}`}>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16 }}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Info note */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Guests will receive an email notification about this booking.
|
||||
</Text>
|
||||
showsVerticalScrollIndicator={!transparentBackground}>
|
||||
{/* Email input */}
|
||||
{transparentBackground && (
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-gray-500">Email *</Text>
|
||||
)}
|
||||
<View
|
||||
className={`mb-3 overflow-hidden rounded-xl px-4 py-3 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}>
|
||||
{!transparentBackground && (
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">Email *</Text>
|
||||
)}
|
||||
<TextInput
|
||||
className="h-10 text-[17px] text-[#000]"
|
||||
placeholder="guest@example.com"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Form Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Email input */}
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">Email *</Text>
|
||||
<TextInput
|
||||
className="h-10 text-[17px] text-[#000]"
|
||||
placeholder="guest@example.com"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Name input */}
|
||||
<View className="px-4 py-3">
|
||||
{/* Name input */}
|
||||
{transparentBackground && (
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-gray-500">Name (optional)</Text>
|
||||
)}
|
||||
<View
|
||||
className={`mb-4 overflow-hidden rounded-xl px-4 py-3 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}>
|
||||
{!transparentBackground && (
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">Name (optional)</Text>
|
||||
<TextInput
|
||||
className="h-10 text-[17px] text-[#000]"
|
||||
placeholder="Guest Name"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<TextInput
|
||||
className="h-10 text-[17px] text-[#000]"
|
||||
placeholder="Guest Name"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Add button */}
|
||||
<TouchableOpacity
|
||||
className="mb-6 flex-row items-center justify-center rounded-xl bg-white py-3"
|
||||
className={`mb-6 flex-row items-center justify-center rounded-xl py-3 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
onPress={handleAddGuest}
|
||||
activeOpacity={0.7}
|
||||
disabled={isSaving}
|
||||
>
|
||||
disabled={isSaving}>
|
||||
<Ionicons name="add-circle" size={22} color="#007AFF" />
|
||||
<Text className="ml-2 text-[17px] font-medium text-[#007AFF]">Add Guest</Text>
|
||||
<Text className="ml-2 text-[17px] font-medium text-[#007AFF]">Add</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Guest list */}
|
||||
@@ -184,28 +204,27 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Guests to add ({guests.length})
|
||||
</Text>
|
||||
<View className="overflow-hidden rounded-xl bg-white">
|
||||
<View
|
||||
className={`overflow-hidden rounded-xl ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}>
|
||||
{guests.map((guest, index) => (
|
||||
<View
|
||||
key={guest.email}
|
||||
className={`flex-row items-center px-4 py-3 ${
|
||||
index < guests.length - 1 ? "border-b border-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
index < guests.length - 1 ? "border-b border-gray-100/50" : ""
|
||||
}`}>
|
||||
<View className={`mr-3 h-10 w-10 items-center justify-center rounded-full ${pillStyle}`}>
|
||||
<Ionicons name="person" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] text-[#000]">{guest.email}</Text>
|
||||
{guest.name && (
|
||||
<Text className="mt-0.5 text-[15px] text-gray-500">{guest.name}</Text>
|
||||
)}
|
||||
{guest.name && <Text className="mt-0.5 text-[15px] text-gray-500">{guest.name}</Text>}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleRemoveGuest(index)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
disabled={isSaving}
|
||||
>
|
||||
disabled={isSaving}>
|
||||
<Ionicons name="close-circle-outline" size={24} color="#800020" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { GlassView } from "expo-glass-effect";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { ActivityIndicator, Alert, ScrollView, Switch, Text, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
@@ -13,6 +13,17 @@ import { showErrorAlert } from "@/utils/alerts";
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
// Map day names to numbers - module scope for React Compiler optimization
|
||||
const DAY_NAME_TO_NUMBER: Record<string, number> = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
@@ -76,10 +87,17 @@ const TIME_OPTIONS = generateTimeOptions();
|
||||
|
||||
export interface AvailabilityDetailScreenProps {
|
||||
id: string;
|
||||
useNativeHeader?: boolean;
|
||||
}
|
||||
|
||||
export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) {
|
||||
"use no memo";
|
||||
export interface AvailabilityDetailScreenHandle {
|
||||
save: () => void;
|
||||
}
|
||||
|
||||
export const AvailabilityDetailScreen = forwardRef<
|
||||
AvailabilityDetailScreenHandle,
|
||||
AvailabilityDetailScreenProps
|
||||
>(function AvailabilityDetailScreen({ id, useNativeHeader = false }, ref) {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -129,98 +147,107 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
|
||||
"UTC",
|
||||
];
|
||||
|
||||
const fetchSchedule = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const scheduleData = await CalComAPIService.getScheduleById(Number(id));
|
||||
const processScheduleData = useCallback(
|
||||
(scheduleData: NonNullable<Awaited<ReturnType<typeof CalComAPIService.getScheduleById>>>) => {
|
||||
const name = scheduleData.name ?? "";
|
||||
const tz = scheduleData.timeZone ?? "UTC";
|
||||
const isDefaultSchedule = scheduleData.isDefault ?? false;
|
||||
|
||||
if (scheduleData) {
|
||||
setSchedule(scheduleData);
|
||||
setScheduleName(scheduleData.name || "");
|
||||
setTimeZone(scheduleData.timeZone || "UTC");
|
||||
setIsDefault(scheduleData.isDefault || false);
|
||||
setSchedule(scheduleData);
|
||||
setScheduleName(name);
|
||||
setTimeZone(tz);
|
||||
setIsDefault(isDefaultSchedule);
|
||||
|
||||
// Convert availability array to day-indexed object
|
||||
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
|
||||
// Convert availability array to day-indexed object
|
||||
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
|
||||
|
||||
// Map day names to numbers
|
||||
const dayNameToNumber: Record<string, number> = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
|
||||
if (scheduleData.availability && Array.isArray(scheduleData.availability)) {
|
||||
scheduleData.availability.forEach((slot) => {
|
||||
// Handle both string day names and number day formats
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
// If it's a day name string (e.g., "Sunday", "Monday")
|
||||
if (typeof day === "string" && dayNameToNumber[day] !== undefined) {
|
||||
return dayNameToNumber[day];
|
||||
const availabilityArray = scheduleData.availability;
|
||||
if (availabilityArray && Array.isArray(availabilityArray)) {
|
||||
availabilityArray.forEach((slot) => {
|
||||
// Handle both string day names and number day formats
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
// If it's a day name string (e.g., "Sunday", "Monday")
|
||||
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
|
||||
return DAY_NAME_TO_NUMBER[day];
|
||||
}
|
||||
// If it's a number string (e.g., "0", "1")
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
// If it's a number string (e.g., "0", "1")
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
// If it's already a number
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
// If it's already a number
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
days.forEach((day) => {
|
||||
if (!availabilityMap[day]) {
|
||||
availabilityMap[day] = [];
|
||||
}
|
||||
|
||||
days.forEach((day) => {
|
||||
if (!availabilityMap[day]) {
|
||||
availabilityMap[day] = [];
|
||||
}
|
||||
availabilityMap[day].push({
|
||||
days: [day.toString()],
|
||||
startTime: slot.startTime || "09:00:00",
|
||||
endTime: slot.endTime || "17:00:00",
|
||||
});
|
||||
const startTime = slot.startTime ?? "09:00:00";
|
||||
const endTime = slot.endTime ?? "17:00:00";
|
||||
availabilityMap[day].push({
|
||||
days: [day.toString()],
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setAvailability(availabilityMap);
|
||||
|
||||
// Load overrides if they exist
|
||||
if (scheduleData.overrides && Array.isArray(scheduleData.overrides)) {
|
||||
const formattedOverrides = scheduleData.overrides.map((override) => ({
|
||||
date: override.date || "",
|
||||
startTime: override.startTime || "00:00",
|
||||
endTime: override.endTime || "00:00",
|
||||
}));
|
||||
setOverrides(formattedOverrides);
|
||||
} else {
|
||||
setOverrides([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
|
||||
setAvailability(availabilityMap);
|
||||
|
||||
// Load overrides if they exist
|
||||
const overridesArray = scheduleData.overrides;
|
||||
if (overridesArray && Array.isArray(overridesArray)) {
|
||||
const formattedOverrides = overridesArray.map((override) => {
|
||||
const date = override.date ?? "";
|
||||
const startTime = override.startTime ?? "00:00";
|
||||
const endTime = override.endTime ?? "00:00";
|
||||
return { date, startTime, endTime };
|
||||
});
|
||||
setOverrides(formattedOverrides);
|
||||
} else {
|
||||
setOverrides([]);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchSchedule = useCallback(async () => {
|
||||
setLoading(true);
|
||||
let scheduleData: Awaited<ReturnType<typeof CalComAPIService.getScheduleById>> = null;
|
||||
try {
|
||||
scheduleData = await CalComAPIService.getScheduleById(Number(id));
|
||||
} catch (error) {
|
||||
// Avoid logging raw error objects from API calls (may contain sensitive response data).
|
||||
console.error("Error fetching schedule");
|
||||
if (__DEV__) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.debug("[AvailabilityDetailScreen] fetchSchedule failed", { message });
|
||||
console.debug("[AvailabilityDetailScreen] fetchSchedule failed", {
|
||||
message,
|
||||
});
|
||||
}
|
||||
showErrorAlert("Error", "Failed to load schedule. Please try again.");
|
||||
router.back();
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
if (scheduleData) {
|
||||
processScheduleData(scheduleData);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [id, router, processScheduleData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -354,6 +381,11 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
|
||||
}
|
||||
};
|
||||
|
||||
// Expose save method to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: handleSave,
|
||||
}));
|
||||
|
||||
const handleSetAsDefault = async () => {
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(Number(id), {
|
||||
@@ -464,52 +496,54 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#f8f9fa]">
|
||||
{/* Glass Header */}
|
||||
<GlassView
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
paddingTop: insets.top + 8,
|
||||
},
|
||||
]}
|
||||
glassEffectStyle="clear"
|
||||
>
|
||||
<View className="min-h-[44px] flex-row items-center justify-between">
|
||||
<AppPressable
|
||||
className="h-10 w-10 items-start justify-center"
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#000" />
|
||||
</AppPressable>
|
||||
{/* Glass Header - only show when not using native header */}
|
||||
{!useNativeHeader && (
|
||||
<GlassView
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
paddingTop: insets.top + 8,
|
||||
},
|
||||
]}
|
||||
glassEffectStyle="clear"
|
||||
>
|
||||
<View className="min-h-[44px] flex-row items-center justify-between">
|
||||
<AppPressable
|
||||
className="h-10 w-10 items-start justify-center"
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#000" />
|
||||
</AppPressable>
|
||||
|
||||
<Text
|
||||
className="mx-2.5 flex-1 text-center text-lg font-semibold text-black"
|
||||
numberOfLines={1}
|
||||
>
|
||||
Edit Availability
|
||||
</Text>
|
||||
<Text
|
||||
className="mx-2.5 flex-1 text-center text-lg font-semibold text-black"
|
||||
numberOfLines={1}
|
||||
>
|
||||
Edit Availability
|
||||
</Text>
|
||||
|
||||
<AppPressable
|
||||
className={`min-w-[60px] items-center rounded-[10px] bg-black px-2 py-2 md:px-4 ${
|
||||
saving ? "opacity-60" : ""
|
||||
}`}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text className="text-base font-semibold text-white">Save</Text>
|
||||
</AppPressable>
|
||||
</View>
|
||||
</GlassView>
|
||||
<AppPressable
|
||||
className={`min-w-[60px] items-center rounded-[10px] bg-black px-2 py-2 md:px-4 ${
|
||||
saving ? "opacity-60" : ""
|
||||
}`}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text className="text-base font-semibold text-white">Save</Text>
|
||||
</AppPressable>
|
||||
</View>
|
||||
</GlassView>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
style={{ paddingTop: insets.top + 70 }}
|
||||
style={{ paddingTop: useNativeHeader ? 0 : insets.top + 70 }}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 200 }}
|
||||
>
|
||||
<View className="gap-4">
|
||||
@@ -551,7 +585,9 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
|
||||
onValueChange={() => toggleDay(dayIndex)}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#fff"
|
||||
style={{ transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }] }}
|
||||
style={{
|
||||
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
className="ml-1 text-base font-medium text-[#333]"
|
||||
@@ -563,7 +599,11 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
|
||||
<View className="flex-row items-center gap-2">
|
||||
<AppPressable
|
||||
onPress={() =>
|
||||
setShowTimePicker({ dayIndex, slotIndex: 0, type: "start" })
|
||||
setShowTimePicker({
|
||||
dayIndex,
|
||||
slotIndex: 0,
|
||||
type: "start",
|
||||
})
|
||||
}
|
||||
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
|
||||
style={{ width: 85 }}
|
||||
@@ -575,7 +615,11 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
|
||||
<Text className="text-base text-[#666]">-</Text>
|
||||
<AppPressable
|
||||
onPress={() =>
|
||||
setShowTimePicker({ dayIndex, slotIndex: 0, type: "end" })
|
||||
setShowTimePicker({
|
||||
dayIndex,
|
||||
slotIndex: 0,
|
||||
type: "end",
|
||||
})
|
||||
}
|
||||
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
|
||||
style={{ width: 85 }}
|
||||
@@ -1017,4 +1061,4 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps)
|
||||
</FullScreenModal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,10 +76,7 @@ export interface BookingDetailScreenProps {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function BookingDetailScreen({
|
||||
uid,
|
||||
onActionsReady,
|
||||
}: BookingDetailScreenProps): React.JSX.Element {
|
||||
export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -108,7 +105,9 @@ export function BookingDetailScreen({
|
||||
console.error("Failed to cancel booking");
|
||||
if (__DEV__) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.debug("[BookingDetailScreen.ios] cancelBooking failed", { message });
|
||||
console.debug("[BookingDetailScreen.ios] cancelBooking failed", {
|
||||
message,
|
||||
});
|
||||
}
|
||||
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
|
||||
setIsCancelling(false);
|
||||
@@ -151,7 +150,7 @@ export function BookingDetailScreen({
|
||||
const openRescheduleModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/reschedule",
|
||||
pathname: "/reschedule",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -159,7 +158,7 @@ export function BookingDetailScreen({
|
||||
const openEditLocationModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/edit-location",
|
||||
pathname: "/edit-location",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -167,7 +166,7 @@ export function BookingDetailScreen({
|
||||
const openAddGuestsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/add-guests",
|
||||
pathname: "/add-guests",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -175,7 +174,7 @@ export function BookingDetailScreen({
|
||||
const openMarkNoShowModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/mark-no-show",
|
||||
pathname: "/mark-no-show",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -183,7 +182,7 @@ export function BookingDetailScreen({
|
||||
const openViewRecordingsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/view-recordings",
|
||||
pathname: "/view-recordings",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -191,7 +190,7 @@ export function BookingDetailScreen({
|
||||
const openMeetingSessionDetailsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/meeting-session-details",
|
||||
pathname: "/meeting-session-details",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -323,15 +322,27 @@ export function BookingDetailScreen({
|
||||
const isNoShow = attendee.noShow || attendee.absent;
|
||||
|
||||
if (isPastBooking && isNoShow) {
|
||||
return { name: "close-circle" as const, color: "#FF3B30", label: "No-show" };
|
||||
return {
|
||||
name: "close-circle" as const,
|
||||
color: "#FF3B30",
|
||||
label: "No-show",
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedStatus === "pending") {
|
||||
return { name: "help-circle" as const, color: "#8E8E93", label: "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: "close-circle-outline" as const,
|
||||
color: "#8E8E93",
|
||||
label: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { name: "checkmark-circle" as const, color: "#34C759", label: null };
|
||||
@@ -346,15 +357,13 @@ export function BookingDetailScreen({
|
||||
paddingTop: 16,
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
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 }}
|
||||
>
|
||||
style={{ letterSpacing: -0.3 }}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
|
||||
@@ -367,9 +376,7 @@ export function BookingDetailScreen({
|
||||
)}
|
||||
{durationFormatted && (
|
||||
<View className="rounded-full bg-[#E5E5EA] px-2.5 py-1">
|
||||
<Text className="text-[13px] font-medium text-[#636366]">
|
||||
{durationFormatted}
|
||||
</Text>
|
||||
<Text className="text-[13px] font-medium text-[#636366]">{durationFormatted}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -389,8 +396,7 @@ export function BookingDetailScreen({
|
||||
<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)}
|
||||
>
|
||||
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>
|
||||
@@ -410,8 +416,9 @@ export function BookingDetailScreen({
|
||||
? booking.hosts.map((host, index) => (
|
||||
<View
|
||||
key={host.email || `host-${index}`}
|
||||
className={`flex-row items-center py-2 ${index > 0 ? "border-t border-[#E5E5EA]" : ""}`}
|
||||
>
|
||||
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"}
|
||||
@@ -436,11 +443,8 @@ export function BookingDetailScreen({
|
||||
<View
|
||||
key={attendee.email}
|
||||
className={`flex-row items-center py-2 ${
|
||||
index > 0 || booking.hosts?.length || booking.user
|
||||
? "border-t border-[#E5E5EA]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
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}
|
||||
@@ -462,8 +466,7 @@ export function BookingDetailScreen({
|
||||
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}
|
||||
@@ -491,23 +494,21 @@ export function BookingDetailScreen({
|
||||
"additionalNotes",
|
||||
];
|
||||
|
||||
const displayableEntries = Object.entries(booking.bookingFieldsResponses).filter(
|
||||
([key, value]) => {
|
||||
if (excludedKeys.includes(key)) return false;
|
||||
const displayableEntries = Object.entries(booking.bookingFieldsResponses).filter(([key, value]) => {
|
||||
if (excludedKeys.includes(key)) return false;
|
||||
|
||||
if (value === null || value === undefined || value === "") return false;
|
||||
if (value === null || value === undefined || value === "") return false;
|
||||
|
||||
if (Array.isArray(value) && value.length === 0) 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 (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;
|
||||
|
||||
@@ -537,10 +538,7 @@ export function BookingDetailScreen({
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<View
|
||||
key={key}
|
||||
className={`py-2 ${index > 0 ? "border-t border-[#E5E5EA]" : ""}`}
|
||||
>
|
||||
<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>
|
||||
@@ -585,8 +583,7 @@ export function BookingDetailScreen({
|
||||
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>
|
||||
@@ -599,12 +596,14 @@ export function BookingDetailScreen({
|
||||
<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 },
|
||||
});
|
||||
}}
|
||||
>
|
||||
const uid = booking.rescheduledToUid;
|
||||
if (uid) {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/booking-detail",
|
||||
params: { uid },
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<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>
|
||||
@@ -615,8 +614,11 @@ export function BookingDetailScreen({
|
||||
|
||||
{booking.reschedulingReason && (
|
||||
<View
|
||||
className={`py-2 ${booking.rescheduledFromUid || booking.rescheduledToUid || booking.fromReschedule ? "border-t border-[#E5E5EA]" : ""}`}
|
||||
>
|
||||
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>
|
||||
@@ -642,9 +644,7 @@ export function BookingDetailScreen({
|
||||
)}
|
||||
|
||||
{booking.cancelledByEmail && (
|
||||
<View
|
||||
className={`py-2 ${booking.cancellationReason ? "border-t border-[#E5E5EA]" : ""}`}
|
||||
>
|
||||
<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>
|
||||
@@ -652,8 +652,11 @@ export function BookingDetailScreen({
|
||||
|
||||
{booking.absentHost && (
|
||||
<View
|
||||
className={`flex-row items-center py-2 ${booking.cancellationReason || booking.cancelledByEmail ? "border-t border-[#E5E5EA]" : ""}`}
|
||||
>
|
||||
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>
|
||||
|
||||
@@ -268,7 +268,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
const openRescheduleModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/reschedule",
|
||||
pathname: "/reschedule",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -277,7 +277,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
const openEditLocationModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/edit-location",
|
||||
pathname: "/edit-location",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -286,7 +286,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
const openAddGuestsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/add-guests",
|
||||
pathname: "/add-guests",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -295,7 +295,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
const openMarkNoShowModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/mark-no-show",
|
||||
pathname: "/mark-no-show",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -304,7 +304,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
const openViewRecordingsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/view-recordings",
|
||||
pathname: "/view-recordings",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -313,7 +313,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
const openMeetingSessionDetailsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/meeting-session-details",
|
||||
pathname: "/meeting-session-details",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
@@ -446,8 +446,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
headerRight: () => (
|
||||
<AppPressable
|
||||
className="h-10 w-10 items-center justify-center rounded-full"
|
||||
onPress={() => setShowActionsModal(true)}
|
||||
>
|
||||
onPress={() => setShowActionsModal(true)}>
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
|
||||
</AppPressable>
|
||||
),
|
||||
@@ -479,9 +478,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<View className="mb-1 flex-row flex-wrap items-center">
|
||||
<Text className="text-base font-medium text-[#333]">
|
||||
{booking.user.name}
|
||||
</Text>
|
||||
<Text className="text-base font-medium text-[#333]">{booking.user.name}</Text>
|
||||
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
|
||||
<Text className="text-xs font-medium text-white">host</Text>
|
||||
</View>
|
||||
@@ -493,8 +490,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
booking.hosts.map((host, hostIndex) => (
|
||||
<View
|
||||
key={host.email ?? host.name}
|
||||
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}
|
||||
>
|
||||
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}>
|
||||
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
|
||||
<Text className="text-base font-semibold text-white">
|
||||
{getInitials(host.name || "Host")}
|
||||
@@ -502,9 +498,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<View className="mb-1 flex-row flex-wrap items-center">
|
||||
<Text className="text-base font-medium text-[#333]">
|
||||
{host.name || "Host"}
|
||||
</Text>
|
||||
<Text className="text-base font-medium text-[#333]">{host.name || "Host"}</Text>
|
||||
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
|
||||
<Text className="text-xs font-medium text-white">host</Text>
|
||||
</View>
|
||||
@@ -523,15 +517,11 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
(attendee as { noShow?: boolean; absent?: boolean }).noShow === true ||
|
||||
(attendee as { noShow?: boolean; absent?: boolean }).absent === true;
|
||||
return (
|
||||
<View
|
||||
key={attendee.email}
|
||||
className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}
|
||||
>
|
||||
<View key={attendee.email} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
|
||||
<View
|
||||
className={`mr-3 h-12 w-12 items-center justify-center rounded-full ${
|
||||
isNoShow ? "bg-[#DC2626]" : "bg-black"
|
||||
}`}
|
||||
>
|
||||
}`}>
|
||||
<Text className="text-base font-semibold text-white">
|
||||
{getInitials(attendee.name)}
|
||||
</Text>
|
||||
@@ -541,16 +531,13 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
<Text
|
||||
className={`text-base font-medium ${
|
||||
isNoShow ? "text-[#DC2626]" : "text-[#333]"
|
||||
}`}
|
||||
>
|
||||
}`}>
|
||||
{attendee.name}
|
||||
</Text>
|
||||
{isNoShow && (
|
||||
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-2 py-0.5">
|
||||
<Ionicons name="eye-off" size={12} color="#DC2626" />
|
||||
<Text className="ml-1 text-xs font-medium text-[#DC2626]">
|
||||
No-show
|
||||
</Text>
|
||||
<Text className="ml-1 text-xs font-medium text-[#DC2626]">No-show</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -570,10 +557,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
|
||||
{locationProvider.url ? (
|
||||
<AppPressable
|
||||
onPress={handleJoinMeeting}
|
||||
className="flex-row flex-wrap items-center"
|
||||
>
|
||||
<AppPressable onPress={handleJoinMeeting} className="flex-row flex-wrap items-center">
|
||||
{locationProvider.iconUrl ? (
|
||||
<SvgImage
|
||||
uri={locationProvider.iconUrl}
|
||||
@@ -604,8 +588,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
) : null}
|
||||
|
||||
{/* Recurring Event Section */}
|
||||
{booking.recurringEventId ||
|
||||
(booking as { recurringBookingUid?: string }).recurringBookingUid ? (
|
||||
{booking.recurringEventId || (booking as { recurringBookingUid?: string }).recurringBookingUid ? (
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-2 text-base font-medium text-[#666]">Recurring Event</Text>
|
||||
<Text className="text-base text-[#666]">Every 2 weeks for 6 occurrences</Text>
|
||||
@@ -624,19 +607,11 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
{locationProvider?.url ? (
|
||||
<AppPressable
|
||||
onPress={handleJoinMeeting}
|
||||
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
|
||||
>
|
||||
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4">
|
||||
{locationProvider.iconUrl ? (
|
||||
<SvgImage
|
||||
uri={locationProvider.iconUrl}
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<SvgImage uri={locationProvider.iconUrl} width={20} height={20} style={{ marginRight: 8 }} />
|
||||
) : null}
|
||||
<Text className="text-base font-semibold text-white">
|
||||
Join {locationProvider.label}
|
||||
</Text>
|
||||
<Text className="text-base font-semibold text-white">Join {locationProvider.label}</Text>
|
||||
</AppPressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
@@ -664,9 +639,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
|
||||
<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>
|
||||
<Text className="mt-3 text-base font-medium text-gray-700">Cancelling booking...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface EditLocationScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
// Handle type for parent component to call submit
|
||||
@@ -68,11 +69,7 @@ const detectLocationType = (location: string): LocationTypeId => {
|
||||
if (!location) return "link";
|
||||
|
||||
// Check if it's a URL
|
||||
if (
|
||||
location.startsWith("http://") ||
|
||||
location.startsWith("https://") ||
|
||||
location.startsWith("www.")
|
||||
) {
|
||||
if (location.startsWith("http://") || location.startsWith("https://") || location.startsWith("www.")) {
|
||||
return "link";
|
||||
}
|
||||
|
||||
@@ -87,18 +84,19 @@ const detectLocationType = (location: string): LocationTypeId => {
|
||||
};
|
||||
|
||||
export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocationScreenProps>(
|
||||
function EditLocationScreen({ booking, onSuccess, onSavingChange }, ref) {
|
||||
function EditLocationScreen({ booking, onSuccess, onSavingChange, transparentBackground = false }, ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
const [selectedType, setSelectedType] = useState<LocationTypeId>("link");
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Pre-fill with current location
|
||||
// Detect location type from current location but don't pre-fill the input
|
||||
useEffect(() => {
|
||||
if (booking?.location) {
|
||||
const detectedType = detectLocationType(booking.location);
|
||||
setSelectedType(detectedType);
|
||||
setInputValue(booking.location);
|
||||
// Don't pre-fill - keep input blank so user can enter new location
|
||||
}
|
||||
}, [booking?.location]);
|
||||
|
||||
@@ -157,9 +155,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateLocationV2(booking.uid, locationPayload);
|
||||
Alert.alert("Success", "Location updated successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
Alert.alert("Success", "Location updated successfully", [{ text: "OK", onPress: onSuccess }]);
|
||||
setIsSaving(false);
|
||||
} catch (error) {
|
||||
safeLogError("[EditLocationScreen] Failed to update location:", error);
|
||||
@@ -179,121 +175,178 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" className="flex-1 bg-[#F2F2F7]">
|
||||
<KeyboardAvoidingView behavior="padding" className={`flex-1 ${backgroundStyle}`}>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16 }}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Current Location Info */}
|
||||
{booking.location && (
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="location" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Current Location</Text>
|
||||
<Text className="mt-0.5 text-[15px] text-[#000]" numberOfLines={2}>
|
||||
{booking.location}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
showsVerticalScrollIndicator={!transparentBackground}>
|
||||
{transparentBackground ? (
|
||||
<>
|
||||
{/* Location Type Selector with Native Context Menu - Glass UI */}
|
||||
<View className="mb-4 flex-row items-center rounded-xl border border-gray-300/40 bg-white/60 px-4 py-3">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/20">
|
||||
<Ionicons name={selectedTypeConfig.iconName} size={20} color="#007AFF" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-[#000]">{selectedTypeConfig.label}</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">{selectedTypeConfig.description}</Text>
|
||||
</View>
|
||||
|
||||
{/* Location Type Selector with Native Context Menu */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Location Type
|
||||
</Text>
|
||||
<View className="mb-4 flex-row items-center rounded-xl bg-white px-4">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
|
||||
<Ionicons name={selectedTypeConfig.iconName} size={20} color="#007AFF" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-[#000]">
|
||||
{/* Native iOS Context Menu Button */}
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
|
||||
activationMethod="singlePress">
|
||||
<ContextMenu.Items>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.link.sfSymbol}
|
||||
onPress={() => handleTypeSelect("link")}
|
||||
label={LOCATION_TYPES.link.label}
|
||||
/>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.phone.sfSymbol}
|
||||
onPress={() => handleTypeSelect("phone")}
|
||||
label={LOCATION_TYPES.phone.label}
|
||||
/>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.address.sfSymbol}
|
||||
onPress={() => handleTypeSelect("address")}
|
||||
label={LOCATION_TYPES.address.label}
|
||||
/>
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image
|
||||
systemName="ellipsis"
|
||||
color="primary"
|
||||
size={20}
|
||||
modifiers={[frame({ height: 18, width: 18 })]}
|
||||
/>
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Location Input - Glass UI */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl border border-gray-300/40 bg-white/60">
|
||||
<TextInput
|
||||
className={`px-4 py-3 text-[17px] text-[#000] ${
|
||||
selectedTypeConfig.inputType === "multiline" ? "min-h-[100px]" : "h-[50px]"
|
||||
}`}
|
||||
placeholder={selectedTypeConfig.placeholder}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
keyboardType={selectedTypeConfig.keyboardType}
|
||||
multiline={selectedTypeConfig.inputType === "multiline"}
|
||||
textAlignVertical={selectedTypeConfig.inputType === "multiline" ? "top" : "center"}
|
||||
autoCapitalize={selectedType === "link" ? "none" : "sentences"}
|
||||
autoCorrect={selectedType !== "link" && selectedType !== "phone"}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Location Info */}
|
||||
{booking.location && (
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="location" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Current Location</Text>
|
||||
<Text className="mt-0.5 text-[15px] text-[#000]" numberOfLines={2}>
|
||||
{booking.location}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Location Type Selector with Native Context Menu */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Location Type
|
||||
</Text>
|
||||
<View className="mb-4 flex-row items-center rounded-xl bg-white px-4">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
|
||||
<Ionicons name={selectedTypeConfig.iconName} size={20} color="#007AFF" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-[#000]">{selectedTypeConfig.label}</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">{selectedTypeConfig.description}</Text>
|
||||
</View>
|
||||
|
||||
{/* Native iOS Context Menu Button */}
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
|
||||
activationMethod="singlePress">
|
||||
<ContextMenu.Items>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.link.sfSymbol}
|
||||
onPress={() => handleTypeSelect("link")}
|
||||
label={LOCATION_TYPES.link.label}
|
||||
/>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.phone.sfSymbol}
|
||||
onPress={() => handleTypeSelect("phone")}
|
||||
label={LOCATION_TYPES.phone.label}
|
||||
/>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.address.sfSymbol}
|
||||
onPress={() => handleTypeSelect("address")}
|
||||
label={LOCATION_TYPES.address.label}
|
||||
/>
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image
|
||||
systemName="ellipsis"
|
||||
color="primary"
|
||||
size={20}
|
||||
modifiers={[frame({ height: 18, width: 18 })]}
|
||||
/>
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Location Input */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">
|
||||
{selectedTypeConfig.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Native iOS Context Menu Button */}
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[
|
||||
buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"),
|
||||
padding(),
|
||||
]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.link.sfSymbol}
|
||||
onPress={() => handleTypeSelect("link")}
|
||||
label={LOCATION_TYPES.link.label}
|
||||
/>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.phone.sfSymbol}
|
||||
onPress={() => handleTypeSelect("phone")}
|
||||
label={LOCATION_TYPES.phone.label}
|
||||
/>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.address.sfSymbol}
|
||||
onPress={() => handleTypeSelect("address")}
|
||||
label={LOCATION_TYPES.address.label}
|
||||
/>
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image
|
||||
systemName="ellipsis"
|
||||
color="primary"
|
||||
size={20}
|
||||
modifiers={[frame({ height: 18, width: 18 })]}
|
||||
/>
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Location Input */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<TextInput
|
||||
className={`px-4 py-3 text-[17px] text-[#000] ${
|
||||
selectedTypeConfig.inputType === "multiline" ? "min-h-[100px]" : "h-[50px]"
|
||||
}`}
|
||||
placeholder={selectedTypeConfig.placeholder}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
keyboardType={selectedTypeConfig.keyboardType}
|
||||
multiline={selectedTypeConfig.inputType === "multiline"}
|
||||
textAlignVertical={selectedTypeConfig.inputType === "multiline" ? "top" : "center"}
|
||||
autoCapitalize={selectedType === "link" ? "none" : "sentences"}
|
||||
autoCorrect={selectedType !== "link" && selectedType !== "phone"}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Info note */}
|
||||
<View className="flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Updating the location will notify attendees. Note: Calendar events may not be updated
|
||||
automatically.
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<TextInput
|
||||
className={`px-4 py-3 text-[17px] text-[#000] ${
|
||||
selectedTypeConfig.inputType === "multiline" ? "min-h-[100px]" : "h-[50px]"
|
||||
}`}
|
||||
placeholder={selectedTypeConfig.placeholder}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
keyboardType={selectedTypeConfig.keyboardType}
|
||||
multiline={selectedTypeConfig.inputType === "multiline"}
|
||||
textAlignVertical={selectedTypeConfig.inputType === "multiline" ? "top" : "center"}
|
||||
autoCapitalize={selectedType === "link" ? "none" : "sentences"}
|
||||
autoCorrect={selectedType !== "link" && selectedType !== "phone"}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface EditLocationScreenProps {
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
selectedType?: LocationTypeId;
|
||||
onTypeChange?: (type: LocationTypeId) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface EditLocationScreenHandle {
|
||||
@@ -85,11 +86,7 @@ export interface EditLocationScreenHandle {
|
||||
const detectLocationType = (location: string): LocationTypeId => {
|
||||
if (!location) return "link";
|
||||
|
||||
if (
|
||||
location.startsWith("http://") ||
|
||||
location.startsWith("https://") ||
|
||||
location.startsWith("www.")
|
||||
) {
|
||||
if (location.startsWith("http://") || location.startsWith("https://") || location.startsWith("www.")) {
|
||||
return "link";
|
||||
}
|
||||
|
||||
@@ -102,18 +99,21 @@ const detectLocationType = (location: string): LocationTypeId => {
|
||||
};
|
||||
|
||||
export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocationScreenProps>(
|
||||
function EditLocationScreen({ booking, onSuccess, onSavingChange }, ref) {
|
||||
function EditLocationScreen({ booking, onSuccess, onSavingChange, transparentBackground = false }, ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]";
|
||||
const [selectedType, setSelectedType] = useState<LocationTypeId>("link");
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [showTypePicker, setShowTypePicker] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Detect location type from current location but don't pre-fill the input
|
||||
useEffect(() => {
|
||||
if (booking?.location) {
|
||||
const detectedType = detectLocationType(booking.location);
|
||||
setSelectedType(detectedType);
|
||||
setInputValue(booking.location);
|
||||
// Don't pre-fill - keep input blank so user can enter new location
|
||||
}
|
||||
}, [booking?.location]);
|
||||
|
||||
@@ -160,9 +160,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateLocationV2(booking.uid, locationPayload);
|
||||
Alert.alert("Success", "Location updated successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
Alert.alert("Success", "Location updated successfully", [{ text: "OK", onPress: onSuccess }]);
|
||||
setIsSaving(false);
|
||||
} catch (error) {
|
||||
safeLogError("[EditLocationScreen] Failed to update location:", error);
|
||||
@@ -181,7 +179,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -191,17 +189,19 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
<>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1 bg-[#F2F2F7]"
|
||||
>
|
||||
className={`flex-1 ${backgroundStyle}`}>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16 }}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Current Location Info */}
|
||||
{booking.location && (
|
||||
showsVerticalScrollIndicator={!transparentBackground}>
|
||||
{/* Current Location Info - hidden in transparent mode */}
|
||||
{!transparentBackground && booking.location && (
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<View className={`mr-3 h-10 w-10 items-center justify-center rounded-full ${pillStyle}`}>
|
||||
<Ionicons name="location" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
@@ -214,34 +214,45 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
)}
|
||||
|
||||
{/* Location Type Selector */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Location Type
|
||||
</Text>
|
||||
{!transparentBackground && (
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Location Type
|
||||
</Text>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
className="mb-4 flex-row items-center rounded-xl bg-white px-4 py-3.5"
|
||||
className={
|
||||
transparentBackground
|
||||
? "mb-4 flex-row items-center rounded-xl border border-gray-300/40 bg-white/60 px-4 py-3.5"
|
||||
: "mb-4 flex-row items-center rounded-xl bg-white px-4 py-3.5"
|
||||
}
|
||||
onPress={() => setShowTypePicker(true)}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-lg bg-[#007AFF]/10">
|
||||
activeOpacity={0.7}>
|
||||
<View
|
||||
className={`mr-3 h-10 w-10 items-center justify-center rounded-lg ${
|
||||
transparentBackground ? "bg-[#007AFF]/20" : "bg-[#007AFF]/15"
|
||||
}`}>
|
||||
<Ionicons name={selectedTypeConfig.iconName} size={22} color="#007AFF" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-[#000]">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">
|
||||
{selectedTypeConfig.description}
|
||||
</Text>
|
||||
<Text className="text-[17px] font-medium text-[#000]">{selectedTypeConfig.label}</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">{selectedTypeConfig.description}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-down" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Location Input */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{!transparentBackground && (
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
className={
|
||||
transparentBackground
|
||||
? "mb-4 overflow-hidden rounded-xl border border-gray-300/40 bg-white/60"
|
||||
: "mb-4 overflow-hidden rounded-xl bg-white"
|
||||
}>
|
||||
<TextInput
|
||||
className={`px-4 py-3 text-[17px] text-[#000] ${
|
||||
selectedTypeConfig.inputType === "multiline" ? "min-h-[100px]" : "h-[50px]"
|
||||
@@ -258,15 +269,6 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Info note */}
|
||||
<View className="flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Updating the location will notify attendees. Note: Calendar events may not be
|
||||
updated automatically.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
@@ -275,17 +277,14 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
visible={showTypePicker}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowTypePicker(false)}
|
||||
>
|
||||
onRequestClose={() => setShowTypePicker(false)}>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowTypePicker(false)}
|
||||
>
|
||||
onPress={() => setShowTypePicker(false)}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
className="max-h-[70%] w-[320px] overflow-hidden rounded-2xl bg-white"
|
||||
>
|
||||
className="max-h-[70%] w-[320px] overflow-hidden rounded-2xl bg-white">
|
||||
<View className="border-b border-gray-200 px-4 py-3">
|
||||
<Text className="text-center text-[17px] font-semibold text-[#000]">
|
||||
Select Location Type
|
||||
@@ -298,34 +297,25 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
const isSelected = item.id === selectedType;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-4 py-3.5 ${
|
||||
isSelected ? "bg-[#EBF4FF]" : ""
|
||||
}`}
|
||||
className={`flex-row items-center px-4 py-3.5 ${isSelected ? "bg-[#EBF4FF]" : ""}`}
|
||||
onPress={() => {
|
||||
setSelectedType(item.id);
|
||||
if (selectedType !== item.id) {
|
||||
setInputValue("");
|
||||
}
|
||||
setShowTypePicker(false);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<View
|
||||
className={`mr-3 h-10 w-10 items-center justify-center rounded-lg ${
|
||||
isSelected ? "bg-[#007AFF]/20" : "bg-[#F2F2F7]"
|
||||
}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={item.iconName}
|
||||
size={22}
|
||||
color={isSelected ? "#007AFF" : "#6B7280"}
|
||||
/>
|
||||
}`}>
|
||||
<Ionicons name={item.iconName} size={22} color={isSelected ? "#007AFF" : "#6B7280"} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
isSelected ? "font-semibold text-[#007AFF]" : "text-[#000]"
|
||||
}`}
|
||||
>
|
||||
}`}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">{item.description}</Text>
|
||||
@@ -337,8 +327,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="border-t border-gray-200 py-3"
|
||||
onPress={() => setShowTypePicker(false)}
|
||||
>
|
||||
onPress={() => setShowTypePicker(false)}>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#007AFF]">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface MarkNoShowScreenProps {
|
||||
attendees: Attendee[];
|
||||
onUpdate: (attendees: Attendee[]) => void;
|
||||
onBookingUpdate?: (booking: Booking) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
const getInitials = (name: string): string => {
|
||||
@@ -50,9 +51,12 @@ export function MarkNoShowScreen({
|
||||
attendees,
|
||||
onUpdate,
|
||||
onBookingUpdate,
|
||||
transparentBackground = false,
|
||||
}: MarkNoShowScreenProps) {
|
||||
"use no memo";
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]";
|
||||
const safeAttendees = Array.isArray(attendees) ? attendees : [];
|
||||
const [processingEmail, setProcessingEmail] = useState<string | null>(null);
|
||||
|
||||
@@ -140,35 +144,35 @@ export function MarkNoShowScreen({
|
||||
const isProcessing = processingEmail === item.email;
|
||||
const isLast = index === safeAttendees.length - 1;
|
||||
|
||||
// For transparent mode (iOS glass UI), use consistent styling regardless of no-show state
|
||||
const itemBgStyle = transparentBackground ? "bg-white/60" : isNoShow ? "bg-[#FEF2F2]" : "bg-white";
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-4 py-4 ${!isLast ? "border-b border-gray-100" : ""} ${
|
||||
isNoShow ? "bg-[#FEF2F2]" : "bg-white"
|
||||
}`}
|
||||
className={`flex-row items-center px-4 py-4 ${
|
||||
transparentBackground
|
||||
? `rounded-xl border border-gray-300/40 ${!isLast ? "mb-3" : ""}`
|
||||
: !isLast
|
||||
? "border-b border-gray-100"
|
||||
: ""
|
||||
} ${itemBgStyle}`}
|
||||
onPress={() => handleMarkNoShow(item)}
|
||||
disabled={isProcessing}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
activeOpacity={0.7}>
|
||||
<View
|
||||
className={`mr-3 h-12 w-12 items-center justify-center rounded-full ${
|
||||
isNoShow ? "bg-[#FECACA]" : "bg-[#E8E8ED]"
|
||||
}`}
|
||||
>
|
||||
transparentBackground ? "bg-[#000]" : isNoShow ? "bg-[#FECACA]" : pillStyle
|
||||
}`}>
|
||||
<Text
|
||||
className={`text-[16px] font-semibold ${isNoShow ? "text-[#DC2626]" : "text-gray-600"}`}
|
||||
>
|
||||
className={`text-[16px] font-semibold ${
|
||||
transparentBackground ? "text-white" : isNoShow ? "text-[#DC2626]" : "text-gray-600"
|
||||
}`}>
|
||||
{getInitials(item.name)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mr-3 flex-1">
|
||||
<Text
|
||||
className={`text-[17px] font-medium ${isNoShow ? "text-[#991B1B]" : "text-[#000]"}`}
|
||||
>
|
||||
{item.name || "Unknown"}
|
||||
</Text>
|
||||
<Text className={`mt-0.5 text-[15px] ${isNoShow ? "text-[#DC2626]" : "text-gray-500"}`}>
|
||||
{item.email}
|
||||
</Text>
|
||||
<Text className="text-[17px] font-medium text-[#000]">{item.name || "Unknown"}</Text>
|
||||
<Text className="mt-0.5 text-[15px] text-gray-500">{item.email}</Text>
|
||||
{isNoShow && (
|
||||
<View className="mt-1.5 flex-row items-center">
|
||||
<Ionicons name="eye-off" size={12} color="#DC2626" />
|
||||
@@ -182,22 +186,15 @@ export function MarkNoShowScreen({
|
||||
<View className="flex-row items-center">
|
||||
<View
|
||||
className={`flex-row items-center rounded-full px-3.5 py-2 ${
|
||||
isNoShow
|
||||
? "border border-[#16A34A] bg-white"
|
||||
: "border border-[#FEE2E2] bg-[#FEE2E2]"
|
||||
}`}
|
||||
>
|
||||
isNoShow ? "border border-[#16A34A] bg-white" : "border border-[#FEE2E2] bg-[#FEE2E2]"
|
||||
}`}>
|
||||
<Ionicons
|
||||
name={isNoShow ? "eye" : "eye-off"}
|
||||
size={15}
|
||||
color={isNoShow ? "#16A34A" : "#DC2626"}
|
||||
style={{ marginRight: 5 }}
|
||||
/>
|
||||
<Text
|
||||
className={`text-[14px] font-semibold ${
|
||||
isNoShow ? "text-[#16A34A]" : "text-[#DC2626]"
|
||||
}`}
|
||||
>
|
||||
<Text className={`text-[14px] font-semibold ${isNoShow ? "text-[#16A34A]" : "text-[#DC2626]"}`}>
|
||||
{isNoShow ? "Unmark" : "Mark"}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -209,23 +206,15 @@ export function MarkNoShowScreen({
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#F2F2F7]">
|
||||
<View className={`flex-1 ${backgroundStyle}`}>
|
||||
<View className="flex-1 p-4">
|
||||
{/* Info note */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Tap an attendee to mark them as no-show or to undo a previous no-show marking.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{safeAttendees.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<View className="mb-4 h-20 w-20 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
@@ -241,15 +230,25 @@ export function MarkNoShowScreen({
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Attendees ({safeAttendees.length})
|
||||
</Text>
|
||||
<View className="overflow-hidden rounded-xl">
|
||||
{transparentBackground ? (
|
||||
<FlatList
|
||||
data={safeAttendees}
|
||||
renderItem={renderAttendee}
|
||||
keyExtractor={(item) => item.email}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="overflow-hidden rounded-xl bg-white">
|
||||
<FlatList
|
||||
data={safeAttendees}
|
||||
renderItem={renderAttendee}
|
||||
keyExtractor={(item) => item.email}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ConferencingSession } from "@/services/types/bookings.types";
|
||||
|
||||
export interface MeetingSessionDetailsScreenProps {
|
||||
sessions: ConferencingSession[];
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
@@ -44,15 +45,26 @@ function formatTime(dateString: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function MeetingSessionDetailsScreen({ sessions }: MeetingSessionDetailsScreenProps) {
|
||||
export function MeetingSessionDetailsScreen({
|
||||
sessions,
|
||||
transparentBackground = false,
|
||||
}: MeetingSessionDetailsScreenProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]";
|
||||
|
||||
const renderSession = ({ item, index }: { item: ConferencingSession; index: number }) => (
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<View
|
||||
className={`mb-4 overflow-hidden rounded-xl ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}>
|
||||
{/* Session header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-100 p-4">
|
||||
<View
|
||||
className={`flex-row items-center justify-between p-4 ${
|
||||
transparentBackground ? "" : "border-b border-gray-100"
|
||||
}`}>
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<View className={`mr-3 h-10 w-10 items-center justify-center rounded-full ${pillStyle}`}>
|
||||
<Ionicons name="videocam" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<Text className="text-[17px] font-semibold text-[#000]">
|
||||
@@ -60,10 +72,8 @@ export function MeetingSessionDetailsScreen({ sessions }: MeetingSessionDetailsS
|
||||
</Text>
|
||||
</View>
|
||||
{item.duration && (
|
||||
<View className="rounded-full bg-[#E8E8ED] px-3 py-1">
|
||||
<Text className="text-[13px] font-medium text-gray-600">
|
||||
{formatDuration(item.duration)}
|
||||
</Text>
|
||||
<View className={`rounded-full px-3 py-1 ${pillStyle}`}>
|
||||
<Text className="text-[13px] font-medium text-gray-600">{formatDuration(item.duration)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -72,40 +82,35 @@ export function MeetingSessionDetailsScreen({ sessions }: MeetingSessionDetailsS
|
||||
<View className="p-4">
|
||||
<View className="mb-2 flex-row items-center">
|
||||
<Ionicons name="time-outline" size={18} color="#8E8E93" />
|
||||
<Text className="ml-2 text-[15px] text-gray-600">
|
||||
Started: {formatDate(item.startTime)}
|
||||
</Text>
|
||||
<Text className="ml-2 text-[15px] text-gray-600">Started: {formatDate(item.startTime)}</Text>
|
||||
</View>
|
||||
{item.endTime && (
|
||||
<View className="mb-2 flex-row items-center">
|
||||
<Ionicons name="time-outline" size={18} color="#8E8E93" />
|
||||
<Text className="ml-2 text-[15px] text-gray-600">
|
||||
Ended: {formatTime(item.endTime)}
|
||||
</Text>
|
||||
<Text className="ml-2 text-[15px] text-gray-600">Ended: {formatTime(item.endTime)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.maxParticipants && (
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="people-outline" size={18} color="#8E8E93" />
|
||||
<Text className="ml-2 text-[15px] text-gray-600">
|
||||
Max participants: {item.maxParticipants}
|
||||
</Text>
|
||||
<Text className="ml-2 text-[15px] text-gray-600">Max participants: {item.maxParticipants}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Participants */}
|
||||
{item.participants && item.participants.length > 0 && (
|
||||
<View className="border-t border-gray-100 p-4">
|
||||
<View className={`p-4 ${transparentBackground ? "" : "border-t border-gray-100"}`}>
|
||||
<Text className="mb-3 text-[13px] font-semibold uppercase tracking-wide text-gray-500">
|
||||
Participants ({item.participants.length})
|
||||
</Text>
|
||||
{item.participants.map((participant, pIndex) => (
|
||||
<View
|
||||
key={participant.id || pIndex}
|
||||
className="mb-2 flex-row items-center rounded-xl bg-[#F2F2F7] p-3"
|
||||
>
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
className={`mb-2 flex-row items-center p-3 ${
|
||||
transparentBackground ? "" : "rounded-xl bg-[#F2F2F7]"
|
||||
}`}>
|
||||
<View className={`mr-3 h-9 w-9 items-center justify-center rounded-full ${pillStyle}`}>
|
||||
<Ionicons name="person" size={18} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
@@ -126,8 +131,8 @@ export function MeetingSessionDetailsScreen({ sessions }: MeetingSessionDetailsS
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7] px-8">
|
||||
<View className="mb-4 h-16 w-16 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<View className={`flex-1 items-center justify-center px-8 ${backgroundStyle}`}>
|
||||
<View className={`mb-4 h-16 w-16 items-center justify-center rounded-full ${pillStyle}`}>
|
||||
<Ionicons name="information-circle" size={32} color="#8E8E93" />
|
||||
</View>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#000]">
|
||||
@@ -141,13 +146,13 @@ export function MeetingSessionDetailsScreen({ sessions }: MeetingSessionDetailsS
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#F2F2F7]">
|
||||
<View className={`flex-1 ${backgroundStyle}`}>
|
||||
<View className="flex-1 p-4" style={{ paddingBottom: insets.bottom }}>
|
||||
<FlatList
|
||||
data={sessions}
|
||||
renderItem={renderSession}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* RescheduleScreen Component (Android)
|
||||
*
|
||||
* Android-specific implementation with modal-based date/time picker.
|
||||
* Uses custom modal dialogs with Material Design 3 inspired styling.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
|
||||
export interface RescheduleScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
useNativeHeader?: boolean;
|
||||
}
|
||||
|
||||
export interface RescheduleScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
// Format time in 12-hour format
|
||||
function formatTime12Hour(date: Date): string {
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const ampm = hours >= 12 ? "PM" : "AM";
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${String(minutes).padStart(2, "0")} ${ampm}`;
|
||||
}
|
||||
|
||||
export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScreenProps>(
|
||||
function RescheduleScreen({ booking, onSuccess, onSavingChange, useNativeHeader = false }, ref) {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (booking?.startTime) {
|
||||
const bookingDate = new Date(booking.startTime);
|
||||
if (!Number.isNaN(bookingDate.getTime())) {
|
||||
setSelectedDateTime(bookingDate);
|
||||
setReason("");
|
||||
}
|
||||
}
|
||||
}, [booking?.startTime]);
|
||||
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
|
||||
if (selectedDateTime <= new Date()) {
|
||||
Alert.alert("Error", "Please select a future date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
const reschedulingReason = reason.trim() || undefined;
|
||||
try {
|
||||
await CalComAPIService.rescheduleBooking(booking.uid, {
|
||||
start: selectedDateTime.toISOString(),
|
||||
reschedulingReason,
|
||||
});
|
||||
Alert.alert("Success", "Booking rescheduled successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
setIsSaving(false);
|
||||
} catch (error) {
|
||||
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedDateTime, reason, onSuccess]);
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// Format time for display (12-hour)
|
||||
const formattedTime = formatTime12Hour(selectedDateTime);
|
||||
|
||||
// Generate date options for picker (next 90 days)
|
||||
const dateOptions = React.useMemo(() => {
|
||||
const options: { label: string; sublabel: string; value: Date }[] = [];
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
const isToday = i === 0;
|
||||
const isTomorrow = i === 1;
|
||||
options.push({
|
||||
label: isToday
|
||||
? "Today"
|
||||
: isTomorrow
|
||||
? "Tomorrow"
|
||||
: date.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
}),
|
||||
sublabel: date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
value: date,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
// Extract booking times for stable dependency references
|
||||
const bookingStartTime = booking?.startTime;
|
||||
const bookingEndTime = booking?.endTime;
|
||||
|
||||
// Calculate booking duration in minutes
|
||||
const bookingDurationMinutes = React.useMemo(() => {
|
||||
if (!bookingStartTime || !bookingEndTime) return 30; // Default to 30 min
|
||||
const start = new Date(bookingStartTime);
|
||||
const end = new Date(bookingEndTime);
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
const durationMin = Math.round(durationMs / (1000 * 60));
|
||||
// Ensure slot interval is reasonable (minimum 5 min, maximum 60 min)
|
||||
if (durationMin < 5) return 5;
|
||||
if (durationMin > 60) return 60;
|
||||
return durationMin;
|
||||
}, [bookingStartTime, bookingEndTime]);
|
||||
|
||||
// Generate time options based on booking duration
|
||||
const timeOptions = React.useMemo(() => {
|
||||
const options: {
|
||||
label: string;
|
||||
value: { hour: number; minute: number };
|
||||
}[] = [];
|
||||
const interval = bookingDurationMinutes;
|
||||
|
||||
for (let totalMinutes = 0; totalMinutes < 24 * 60; totalMinutes += interval) {
|
||||
const hour = Math.floor(totalMinutes / 60);
|
||||
const minute = totalMinutes % 60;
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const displayHour = hour % 12 || 12;
|
||||
const time = `${displayHour}:${String(minute).padStart(2, "0")} ${ampm}`;
|
||||
options.push({
|
||||
label: time,
|
||||
value: { hour, minute },
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [bookingDurationMinutes]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardAvoidingView behavior="height" className="flex-1 bg-[#F2F2F7]">
|
||||
{/* Header - only shown when not using native header */}
|
||||
{!useNativeHeader && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E5E5EA",
|
||||
elevation: 2,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
<AppPressable
|
||||
onPress={() => router.back()}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#000" />
|
||||
</AppPressable>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#000",
|
||||
marginHorizontal: 10,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Reschedule
|
||||
</Text>
|
||||
|
||||
<AppPressable
|
||||
onPress={handleSubmit}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
opacity: isSaving ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#007AFF",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Text>
|
||||
</AppPressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Booking Title Card */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="calendar" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
|
||||
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Form Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Date picker trigger */}
|
||||
<Pressable
|
||||
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? "#F9F9F9" : "transparent",
|
||||
})}
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening date picker");
|
||||
setShowDatePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Date</Text>
|
||||
<Text className="text-[17px] text-[#000]">{formattedDate}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</Pressable>
|
||||
|
||||
{/* Time picker trigger */}
|
||||
<Pressable
|
||||
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? "#F9F9F9" : "transparent",
|
||||
})}
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening time picker");
|
||||
setShowTimePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Time</Text>
|
||||
<Text className="text-[17px] text-[#000]">{formattedTime}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</Pressable>
|
||||
|
||||
{/* Reason input */}
|
||||
<View className="px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
|
||||
Reason (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Date Picker Modal */}
|
||||
<Modal
|
||||
visible={showDatePicker}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowDatePicker(false)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
onPress={() => setShowDatePicker(false)}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<Pressable onPress={() => setShowDatePicker(false)}>
|
||||
<Text className="text-[17px] text-gray-500">Cancel</Text>
|
||||
</Pressable>
|
||||
<Text className="text-[17px] font-semibold text-[#000]">Select Date</Text>
|
||||
<Pressable onPress={() => setShowDatePicker(false)}>
|
||||
<Text className="text-[17px] font-semibold text-[#007AFF]">Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Date List */}
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{dateOptions.map((option, index) => {
|
||||
const isSelected =
|
||||
option.value.toDateString() === selectedDateTime.toDateString();
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value.toISOString()}
|
||||
className="flex-row items-center justify-between px-4 py-3.5"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: isSelected
|
||||
? "rgba(0, 122, 255, 0.1)"
|
||||
: pressed
|
||||
? "#F5F5F5"
|
||||
: "#fff",
|
||||
borderBottomWidth: index < dateOptions.length - 1 ? 1 : 0,
|
||||
borderBottomColor: "#F2F2F7",
|
||||
})}
|
||||
onPress={() => {
|
||||
const newDate = new Date(option.value);
|
||||
newDate.setHours(selectedDateTime.getHours());
|
||||
newDate.setMinutes(selectedDateTime.getMinutes());
|
||||
setSelectedDateTime(newDate);
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
isSelected ? "font-semibold text-[#007AFF]" : "font-medium text-[#000]"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text
|
||||
className={`mt-0.5 text-[13px] ${isSelected ? "text-[#007AFF]" : "text-gray-500"}`}
|
||||
>
|
||||
{option.sublabel}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && <Ionicons name="checkmark" size={22} color="#007AFF" />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Time Picker Modal */}
|
||||
<Modal
|
||||
visible={showTimePicker}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowTimePicker(false)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
onPress={() => setShowTimePicker(false)}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "60%",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<Pressable onPress={() => setShowTimePicker(false)}>
|
||||
<Text className="text-[17px] text-gray-500">Cancel</Text>
|
||||
</Pressable>
|
||||
<Text className="text-[17px] font-semibold text-[#000]">Select Time</Text>
|
||||
<Pressable onPress={() => setShowTimePicker(false)}>
|
||||
<Text className="text-[17px] font-semibold text-[#007AFF]">Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Time Grid */}
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{timeOptions.map((option) => {
|
||||
const isSelected =
|
||||
selectedDateTime.getHours() === option.value.hour &&
|
||||
selectedDateTime.getMinutes() === option.value.minute;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.label}
|
||||
style={{ width: "25%", padding: 4 }}
|
||||
onPress={() => {
|
||||
const newDate = new Date(selectedDateTime);
|
||||
newDate.setHours(option.value.hour);
|
||||
newDate.setMinutes(option.value.minute);
|
||||
setSelectedDateTime(newDate);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`items-center justify-center rounded-lg py-3 ${
|
||||
isSelected ? "bg-[#007AFF]" : "bg-[#F2F2F7]"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[13px] ${
|
||||
isSelected ? "font-semibold text-white" : "font-medium text-[#000]"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default RescheduleScreen;
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* RescheduleScreen Component (iOS)
|
||||
*
|
||||
* iOS-specific implementation with native DatePicker for date/time selection.
|
||||
* Uses @expo/ui/swift-ui DatePicker for the native iOS picker.
|
||||
*/
|
||||
|
||||
import { DatePicker, Host } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
|
||||
export interface RescheduleScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
transparentBackground?: boolean;
|
||||
useNativeHeader?: boolean;
|
||||
}
|
||||
|
||||
export interface RescheduleScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScreenProps>(
|
||||
function RescheduleScreen(
|
||||
{ booking, onSuccess, onSavingChange, transparentBackground = false },
|
||||
ref
|
||||
) {
|
||||
"use no memo";
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||
const [reason, setReason] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (booking?.startTime) {
|
||||
const bookingDate = new Date(booking.startTime);
|
||||
if (!Number.isNaN(bookingDate.getTime())) {
|
||||
setSelectedDateTime(bookingDate);
|
||||
setReason("");
|
||||
}
|
||||
}
|
||||
}, [booking?.startTime]);
|
||||
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
|
||||
if (selectedDateTime <= new Date()) {
|
||||
Alert.alert("Error", "Please select a future date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.rescheduleBooking(booking.uid, {
|
||||
start: selectedDateTime.toISOString(),
|
||||
reschedulingReason: reason.trim() || undefined,
|
||||
});
|
||||
Alert.alert("Success", "Booking rescheduled successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
setIsSaving(false);
|
||||
} catch (error) {
|
||||
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedDateTime, reason, onSuccess]);
|
||||
|
||||
const handleDateSelected = useCallback(
|
||||
(date: Date) => {
|
||||
safeLogInfo("[RescheduleScreen] Date selected:", date);
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(selectedDateTime.getHours());
|
||||
newDate.setMinutes(selectedDateTime.getMinutes());
|
||||
setSelectedDateTime(newDate);
|
||||
},
|
||||
[selectedDateTime]
|
||||
);
|
||||
|
||||
const handleTimeSelected = useCallback(
|
||||
(date: Date) => {
|
||||
safeLogInfo("[RescheduleScreen] Time selected:", date);
|
||||
const newDate = new Date(selectedDateTime);
|
||||
newDate.setHours(date.getHours());
|
||||
newDate.setMinutes(date.getMinutes());
|
||||
setSelectedDateTime(newDate);
|
||||
},
|
||||
[selectedDateTime]
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" className={`flex-1 ${backgroundStyle}`}>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
>
|
||||
{transparentBackground ? (
|
||||
<>
|
||||
{/* Booking Title */}
|
||||
<Text
|
||||
className="mb-4 mt-4 text-center text-[17px] font-semibold text-[#000]"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{booking.title}
|
||||
</Text>
|
||||
|
||||
{/* Date and Time pickers - side by side */}
|
||||
<View className="mb-4 flex-row items-center justify-center">
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={handleDateSelected}
|
||||
displayedComponents={["date"]}
|
||||
selection={selectedDateTime}
|
||||
/>
|
||||
</Host>
|
||||
<Text className="mx-2 text-[15px] text-gray-500">at</Text>
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={handleTimeSelected}
|
||||
displayedComponents={["hourAndMinute"]}
|
||||
selection={selectedDateTime}
|
||||
/>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Reason input */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-gray-500">
|
||||
Reason (optional)
|
||||
</Text>
|
||||
<View className="mb-3 overflow-hidden rounded-xl border border-gray-300/40 bg-white/60 px-4 py-3">
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Booking Title Card */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="calendar" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
|
||||
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Form Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Date picker */}
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="mb-2 text-[13px] font-medium text-gray-500">New Date</Text>
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={handleDateSelected}
|
||||
displayedComponents={["date"]}
|
||||
selection={selectedDateTime}
|
||||
/>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Time picker */}
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="mb-2 text-[13px] font-medium text-gray-500">New Time</Text>
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={handleTimeSelected}
|
||||
displayedComponents={["hourAndMinute"]}
|
||||
selection={selectedDateTime}
|
||||
/>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Reason input */}
|
||||
<View className="px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
|
||||
Reason (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default RescheduleScreen;
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Screen content for rescheduling a booking.
|
||||
* Used with the reschedule route that has native Stack.Header.
|
||||
* This is the web/extension fallback - uses native HTML inputs on web.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
@@ -23,13 +24,14 @@ import type { Booking } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
|
||||
// Note: @expo/ui DateTimePicker components are not yet stable
|
||||
// Using a simple inline picker approach instead
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
export interface RescheduleScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
transparentBackground?: boolean;
|
||||
useNativeHeader?: boolean;
|
||||
}
|
||||
|
||||
// Handle type for parent component to call submit
|
||||
@@ -38,9 +40,13 @@ export interface RescheduleScreenHandle {
|
||||
}
|
||||
|
||||
export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScreenProps>(
|
||||
function RescheduleScreen({ booking, onSuccess, onSavingChange }, ref) {
|
||||
"use no memo";
|
||||
function RescheduleScreen(
|
||||
{ booking, onSuccess, onSavingChange, transparentBackground = false },
|
||||
ref
|
||||
) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]";
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||
@@ -72,11 +78,16 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract conditional values before try/catch for React Compiler optimization
|
||||
const trimmedReason = reason.trim();
|
||||
const reschedulingReason = trimmedReason.length > 0 ? trimmedReason : undefined;
|
||||
const startTime = selectedDateTime.toISOString();
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.rescheduleBooking(booking.uid, {
|
||||
start: selectedDateTime.toISOString(),
|
||||
reschedulingReason: reason.trim() || undefined,
|
||||
start: startTime,
|
||||
reschedulingReason,
|
||||
});
|
||||
Alert.alert("Success", "Booking rescheduled successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
@@ -89,6 +100,14 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
}
|
||||
}, [booking, selectedDateTime, reason, onSuccess]);
|
||||
|
||||
// Helper function to format date as YYYY-MM-DD in local timezone (avoids UTC conversion issues)
|
||||
const formatLocalDate = (date: Date) => {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
@@ -125,7 +144,10 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
|
||||
// Generate time options (every 15 minutes)
|
||||
const timeOptions = React.useMemo(() => {
|
||||
const options: { label: string; value: { hour: number; minute: number } }[] = [];
|
||||
const options: {
|
||||
label: string;
|
||||
value: { hour: number; minute: number };
|
||||
}[] = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const time = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
@@ -149,7 +171,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -159,91 +181,204 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
<>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1 bg-[#F2F2F7]"
|
||||
className={`flex-1 ${backgroundStyle}`}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16 }}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
>
|
||||
{/* Booking Title Card */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="calendar" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
|
||||
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Form Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Date picker */}
|
||||
<TouchableOpacity
|
||||
className="border-b border-gray-100 px-4 py-3"
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening date picker");
|
||||
setShowDatePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">New Date</Text>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="h-10 text-[17px] text-[#000]" style={{ lineHeight: 40 }}>
|
||||
{formattedDate}
|
||||
</Text>
|
||||
<Ionicons name="calendar-outline" size={20} color="#007AFF" />
|
||||
{!transparentBackground && (
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View
|
||||
className={`mr-3 h-10 w-10 items-center justify-center rounded-full ${pillStyle}`}
|
||||
>
|
||||
<Ionicons name="calendar" size={20} color="#6B7280" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Time picker */}
|
||||
<TouchableOpacity
|
||||
className="border-b border-gray-100 px-4 py-3"
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening time picker");
|
||||
setShowTimePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">New Time</Text>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="h-10 text-[17px] text-[#000]" style={{ lineHeight: 40 }}>
|
||||
{formattedTime}
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
|
||||
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
<Ionicons name="time-outline" size={20} color="#007AFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Reason input */}
|
||||
<View className="px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
|
||||
Reason (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Info note */}
|
||||
<View className="flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Attendees will receive an email notification about the new time.
|
||||
</Text>
|
||||
</View>
|
||||
{/* Form - Separate cards for transparent mode, grouped for non-transparent */}
|
||||
{transparentBackground ? (
|
||||
<>
|
||||
{/* Date picker - pill button */}
|
||||
{!isWeb && (
|
||||
<View className="mb-3 flex-row items-center">
|
||||
<Text className="mr-3 text-[15px] font-medium text-gray-600">Date</Text>
|
||||
<TouchableOpacity
|
||||
className="rounded-xl border border-gray-300/40 bg-white/60 px-4 py-2.5"
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening date picker");
|
||||
setShowDatePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="text-[17px] font-medium text-[#000]">{formattedDate}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Time picker - pill button */}
|
||||
{!isWeb && (
|
||||
<View className="mb-3 flex-row items-center">
|
||||
<Text className="mr-3 text-[15px] font-medium text-gray-600">Time</Text>
|
||||
<TouchableOpacity
|
||||
className="rounded-xl border border-gray-300/40 bg-white/60 px-4 py-2.5"
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening time picker");
|
||||
setShowTimePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="text-[17px] font-medium text-[#000]">{formattedTime}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Reason input */}
|
||||
<View className="mb-3 overflow-hidden rounded-xl border border-gray-300/40 bg-white/60 px-4 py-3">
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Reason for rescheduling (optional)..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Date picker */}
|
||||
{isWeb ? (
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">New Date</Text>
|
||||
<input
|
||||
type="date"
|
||||
value={formatLocalDate(selectedDateTime)}
|
||||
onChange={(e) => {
|
||||
const [year, month, day] = e.target.value.split("-").map(Number);
|
||||
if (!Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
|
||||
const newDate = new Date(selectedDateTime);
|
||||
newDate.setFullYear(year, month - 1, day);
|
||||
safeLogInfo("[RescheduleScreen] Date selected:", newDate);
|
||||
setSelectedDateTime(newDate);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 40,
|
||||
fontSize: 17,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
backgroundColor: "transparent",
|
||||
color: "#000",
|
||||
}}
|
||||
min={formatLocalDate(new Date())}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">New Date</Text>
|
||||
<TouchableOpacity
|
||||
className={`self-start rounded-lg px-4 py-2 ${pillStyle}`}
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening date picker");
|
||||
setShowDatePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="text-[17px] font-medium text-[#000]">{formattedDate}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Time picker */}
|
||||
{isWeb ? (
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">New Time</Text>
|
||||
<input
|
||||
type="time"
|
||||
value={`${String(selectedDateTime.getHours()).padStart(2, "0")}:${String(
|
||||
selectedDateTime.getMinutes()
|
||||
).padStart(2, "0")}`}
|
||||
onChange={(e) => {
|
||||
const [hours, minutes] = e.target.value.split(":").map(Number);
|
||||
if (!Number.isNaN(hours) && !Number.isNaN(minutes)) {
|
||||
safeLogInfo("[RescheduleScreen] Time selected:", {
|
||||
hours,
|
||||
minutes,
|
||||
});
|
||||
const newDate = new Date(selectedDateTime);
|
||||
newDate.setHours(hours);
|
||||
newDate.setMinutes(minutes);
|
||||
setSelectedDateTime(newDate);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 40,
|
||||
fontSize: 17,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
backgroundColor: "transparent",
|
||||
color: "#000",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">New Time</Text>
|
||||
<TouchableOpacity
|
||||
className={`self-start rounded-lg px-4 py-2 ${pillStyle}`}
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening time picker");
|
||||
setShowTimePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="text-[17px] font-medium text-[#000]">{formattedTime}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Reason input */}
|
||||
<View className="px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
|
||||
Reason (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { BookingRecording } from "@/services/types/bookings.types";
|
||||
|
||||
export interface ViewRecordingsScreenProps {
|
||||
recordings: BookingRecording[];
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
@@ -34,8 +35,13 @@ function formatDate(dateString: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function ViewRecordingsScreen({ recordings }: ViewRecordingsScreenProps) {
|
||||
export function ViewRecordingsScreen({
|
||||
recordings,
|
||||
transparentBackground = false,
|
||||
}: ViewRecordingsScreenProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]";
|
||||
|
||||
const handleOpenRecording = async (recording: BookingRecording) => {
|
||||
try {
|
||||
@@ -49,12 +55,13 @@ export function ViewRecordingsScreen({ recordings }: ViewRecordingsScreenProps)
|
||||
|
||||
const renderRecording = ({ item }: { item: BookingRecording }) => (
|
||||
<TouchableOpacity
|
||||
className="mb-3 overflow-hidden rounded-xl bg-white"
|
||||
className={`mb-3 overflow-hidden rounded-xl ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
onPress={() => handleOpenRecording(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
activeOpacity={0.7}>
|
||||
<View className="flex-row items-center p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<View className={`mr-3 h-10 w-10 items-center justify-center rounded-full ${pillStyle}`}>
|
||||
<Ionicons name="videocam" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
@@ -73,13 +80,11 @@ export function ViewRecordingsScreen({ recordings }: ViewRecordingsScreenProps)
|
||||
|
||||
if (recordings.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7] px-8">
|
||||
<View className="mb-4 h-16 w-16 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<View className={`flex-1 items-center justify-center px-8 ${backgroundStyle}`}>
|
||||
<View className={`mb-4 h-16 w-16 items-center justify-center rounded-full ${pillStyle}`}>
|
||||
<Ionicons name="videocam-off" size={32} color="#8E8E93" />
|
||||
</View>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#000]">
|
||||
No recordings available
|
||||
</Text>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#000]">No recordings available</Text>
|
||||
<Text className="mt-2 text-center text-[15px] leading-5 text-gray-500">
|
||||
Recordings may take some time to process after the meeting ends.
|
||||
</Text>
|
||||
@@ -88,13 +93,13 @@ export function ViewRecordingsScreen({ recordings }: ViewRecordingsScreenProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#F2F2F7]">
|
||||
<View className={`flex-1 ${backgroundStyle}`}>
|
||||
<View className="flex-1 p-4" style={{ paddingBottom: insets.bottom }}>
|
||||
<FlatList
|
||||
data={recordings}
|
||||
renderItem={renderRecording}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -346,12 +346,16 @@ export const useBookingActions = ({
|
||||
|
||||
/**
|
||||
* Submit rejection with reason (used by iOS custom modal)
|
||||
* @param reasonOverride - Optional reason passed directly to avoid race condition with state updates
|
||||
*/
|
||||
const handleSubmitReject = () => {
|
||||
const handleSubmitReject = (reasonOverride?: string) => {
|
||||
if (!rejectBooking) return;
|
||||
|
||||
// Use passed reason if provided, otherwise fall back to state
|
||||
const reason = reasonOverride !== undefined ? reasonOverride : rejectReason;
|
||||
|
||||
declineMutation(
|
||||
{ uid: rejectBooking.uid, reason: rejectReason || undefined },
|
||||
{ uid: rejectBooking.uid, reason: reason || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowRejectModal(false);
|
||||
|
||||
@@ -337,11 +337,15 @@ async function testRawBookingsAPI(): Promise<void> {
|
||||
if (responseText?.trim()) {
|
||||
const _responseJson = safeParseJson(responseText);
|
||||
if (!_responseJson) {
|
||||
safeLogError("[CalComAPIService] Failed to parse bookings response", { responseText });
|
||||
safeLogError("[CalComAPIService] Failed to parse bookings response", {
|
||||
responseText,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
safeLogError("[CalComAPIService] testRawBookingsAPI failed", { error: _error });
|
||||
safeLogError("[CalComAPIService] testRawBookingsAPI failed", {
|
||||
error: _error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -928,7 +932,7 @@ async function getBookingByUid(bookingUid: string): Promise<Booking> {
|
||||
}
|
||||
throw new Error("Invalid response from get booking API");
|
||||
} catch (error) {
|
||||
console.error("getBookingByUid error");
|
||||
console.error("getBookingByUid error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1008,7 +1012,7 @@ async function getBookings(filters?: {
|
||||
}
|
||||
|
||||
// Get current user to filter bookings
|
||||
let currentUser;
|
||||
let currentUser: UserProfile | undefined;
|
||||
try {
|
||||
currentUser = await getCurrentUser();
|
||||
} catch (_error) {
|
||||
|
||||
Reference in New Issue
Block a user