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:
Dhairyashil Shinde
2026-01-01 18:48:30 -03:00
committed by GitHub
co-authored by Volnei Munhoz
parent bbf9274d37
commit 6339f77efc
36 changed files with 3626 additions and 1357 deletions
@@ -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
View File
@@ -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 />
+125
View File
@@ -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>
+33 -3
View File
@@ -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"} />
</>
);
}
+119
View File
@@ -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>
</>
);
}
@@ -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>
File diff suppressed because it is too large Load Diff
+137
View File
@@ -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>
</>
);
}
@@ -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>
)}
+119
View File
@@ -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
/>
</>
);
+93
View File
@@ -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>
</>
);
}
@@ -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>
)}
+85
View File
@@ -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;
+217 -82
View File
@@ -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>
+6 -2
View File
@@ -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);
+8 -4
View File
@@ -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) {