From 6339f77efcb9172345a7ebf56bcc1ec8222187b3 Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Fri, 2 Jan 2026 03:18:30 +0530 Subject: [PATCH] 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 --- companion/app/(tabs)/(bookings)/_layout.tsx | 6 - .../app/(tabs)/(event-types)/index.ios.tsx | 121 +- companion/app/_layout.tsx | 139 +- companion/app/add-guests.ios.tsx | 125 ++ .../{(tabs)/(bookings) => }/add-guests.tsx | 30 +- companion/app/availability-detail.tsx | 36 +- companion/app/edit-location.ios.tsx | 119 ++ .../{(tabs)/(bookings) => }/edit-location.tsx | 30 +- companion/app/event-type-detail.tsx | 1128 +++++++++-------- companion/app/mark-no-show.ios.tsx | 137 ++ .../{(tabs)/(bookings) => }/mark-no-show.tsx | 26 +- companion/app/meeting-session-details.ios.tsx | 98 ++ .../meeting-session-details.tsx | 26 +- companion/app/reschedule.ios.tsx | 119 ++ .../{(tabs)/(bookings) => }/reschedule.tsx | 30 +- companion/app/view-recordings.ios.tsx | 93 ++ .../(bookings) => }/view-recordings.tsx | 26 +- companion/biome.json | 85 ++ .../components/LogoutConfirmModal.ios.tsx | 43 + .../booking-list-screen/BookingListScreen.tsx | 48 +- .../booking-modals/BookingModals.ios.tsx | 234 ++++ .../booking-modals/BookingModals.tsx | 8 +- .../components/screens/AddGuestsScreen.tsx | 129 +- .../screens/AvailabilityDetailScreen.tsx | 288 +++-- .../screens/BookingDetailScreen.ios.tsx | 131 +- .../screens/BookingDetailScreen.tsx | 67 +- .../screens/EditLocationScreen.ios.tsx | 281 ++-- .../components/screens/EditLocationScreen.tsx | 121 +- .../components/screens/MarkNoShowScreen.tsx | 81 +- .../screens/MeetingSessionDetailsScreen.tsx | 55 +- .../screens/RescheduleScreen.android.tsx | 534 ++++++++ .../screens/RescheduleScreen.ios.tsx | 241 ++++ .../components/screens/RescheduleScreen.tsx | 299 +++-- .../screens/ViewRecordingsScreen.tsx | 29 +- companion/hooks/useBookingActions.ts | 8 +- companion/services/calcom.ts | 12 +- 36 files changed, 3626 insertions(+), 1357 deletions(-) create mode 100644 companion/app/add-guests.ios.tsx rename companion/app/{(tabs)/(bookings) => }/add-guests.tsx (76%) create mode 100644 companion/app/edit-location.ios.tsx rename companion/app/{(tabs)/(bookings) => }/edit-location.tsx (76%) create mode 100644 companion/app/mark-no-show.ios.tsx rename companion/app/{(tabs)/(bookings) => }/mark-no-show.tsx (82%) create mode 100644 companion/app/meeting-session-details.ios.tsx rename companion/app/{(tabs)/(bookings) => }/meeting-session-details.tsx (73%) create mode 100644 companion/app/reschedule.ios.tsx rename companion/app/{(tabs)/(bookings) => }/reschedule.tsx (76%) create mode 100644 companion/app/view-recordings.ios.tsx rename companion/app/{(tabs)/(bookings) => }/view-recordings.tsx (73%) create mode 100644 companion/biome.json create mode 100644 companion/components/LogoutConfirmModal.ios.tsx create mode 100644 companion/components/booking-modals/BookingModals.ios.tsx create mode 100644 companion/components/screens/RescheduleScreen.android.tsx create mode 100644 companion/components/screens/RescheduleScreen.ios.tsx diff --git a/companion/app/(tabs)/(bookings)/_layout.tsx b/companion/app/(tabs)/(bookings)/_layout.tsx index 0eef26ba20..972e613628 100644 --- a/companion/app/(tabs)/(bookings)/_layout.tsx +++ b/companion/app/(tabs)/(bookings)/_layout.tsx @@ -6,12 +6,6 @@ export default function BookingsLayout() { - - - - - - ); } diff --git a/companion/app/(tabs)/(event-types)/index.ios.tsx b/companion/app/(tabs)/(event-types)/index.ios.tsx index c02ed5cc45..210db493a0 100644 --- a/companion/app/(tabs)/(event-types)/index.ios.tsx +++ b/companion/app/(tabs)/(event-types)/index.ios.tsx @@ -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(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() { - - {/* Delete Confirmation Modal */} - { - if (!isDeleting) { - setShowDeleteModal(false); - setEventTypeToDelete(null); - } - }}> - - - {/* Header with icon and title */} - - - {/* Danger icon */} - - - - - {/* Title and description */} - - Delete Event Type - - {eventTypeToDelete ? ( - <> - This will permanently delete the "{eventTypeToDelete.title}" event type. This action - cannot be undone. - - ) : null} - - - - - - {/* Footer with buttons */} - - - Delete - - - { - setShowDeleteModal(false); - setEventTypeToDelete(null); - }} - disabled={isDeleting}> - Cancel - - - - - ); } diff --git a/companion/app/_layout.tsx b/companion/app/_layout.tsx index 88c8931bef..c22b3724c0 100644 --- a/companion/app/_layout.tsx +++ b/companion/app/_layout.tsx @@ -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", }} /> + + + + + + ) : ( @@ -63,8 +185,7 @@ function RootLayoutContent() { return ( + className={containerClass}> {content} diff --git a/companion/app/add-guests.ios.tsx b/companion/app/add-guests.ios.tsx new file mode 100644 index 0000000000..d94c585db5 --- /dev/null +++ b/companion/app/add-guests.ios.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [guestCount, setGuestCount] = useState(0); + + const addGuestsScreenRef = useRef(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 ( + <> + + + + + router.back()}> + + + + + Add Guests + + + {showSaveButton ? ( + + + + ) : null} + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/add-guests.tsx b/companion/app/add-guests.tsx similarity index 76% rename from companion/app/(tabs)/(bookings)/add-guests.tsx rename to companion/app/add-guests.tsx index 6d994f89cb..df99d002e1 100644 --- a/companion/app/(tabs)/(bookings)/add-guests.tsx +++ b/companion/app/add-guests.tsx @@ -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( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + const renderHeaderRight = useCallback( () => ( - Save + ), [handleSave, isSaving] @@ -63,9 +73,8 @@ export default function AddGuests() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Add Guests )} @@ -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" && ( - + + + router.back()}> + + + + Add Guests - Save + diff --git a/companion/app/availability-detail.tsx b/companion/app/availability-detail.tsx index 88d107038a..6aa2766ca3 100644 --- a/companion/app/availability-detail.tsx +++ b/companion/app/availability-detail.tsx @@ -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(null); if (!id) { return null; } + const handleSave = () => { + screenRef.current?.save(); + }; + return ( <> - + + {Platform.OS === "ios" && ( + + + router.back()}> + + + + + Edit Availability + + + + Save + + + + )} + + ); } diff --git a/companion/app/edit-location.ios.tsx b/companion/app/edit-location.ios.tsx new file mode 100644 index 0000000000..e823988b32 --- /dev/null +++ b/companion/app/edit-location.ios.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const editLocationScreenRef = useRef(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 ( + <> + + + + + router.back()}> + + + + + Edit Location + + + + + + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/edit-location.tsx b/companion/app/edit-location.tsx similarity index 76% rename from companion/app/(tabs)/(bookings)/edit-location.tsx rename to companion/app/edit-location.tsx index 58050595ef..345de79eda 100644 --- a/companion/app/(tabs)/(bookings)/edit-location.tsx +++ b/companion/app/edit-location.tsx @@ -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( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + const renderHeaderRight = useCallback( () => ( - Save + ), [handleSave, isSaving] @@ -63,9 +73,8 @@ export default function EditLocation() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Edit Location )} @@ -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" && ( - + + + router.back()}> + + + + Edit Location - Save + diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx index 5461e0097b..bbfec5e25e 100644 --- a/companion/app/event-type-detail.tsx +++ b/companion/app/event-type-detail.tsx @@ -15,6 +15,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppPressable } from "@/components/AppPressable"; import { AdvancedTab } from "@/components/event-type-detail/tabs/AdvancedTab"; import { AvailabilityTab } from "@/components/event-type-detail/tabs/AvailabilityTab"; import { BasicsTab } from "@/components/event-type-detail/tabs/BasicsTab"; @@ -117,7 +118,6 @@ const tabs: Tab[] = [ ]; export default function EventTypeDetail() { - "use no memo"; const router = useRouter(); const { id, title, description, duration, slug } = useLocalSearchParams<{ id: string; @@ -363,18 +363,21 @@ export default function EventTypeDetail() { const fetchScheduleDetails = useCallback(async (scheduleId: number) => { setScheduleDetailsLoading(true); + let scheduleDetails: Awaited> | null = null; try { - const scheduleDetails = await CalComAPIService.getScheduleById(scheduleId); - setSelectedScheduleDetails(scheduleDetails); - if (scheduleDetails?.timeZone) { - setSelectedTimezone(scheduleDetails.timeZone); - } - setScheduleDetailsLoading(false); + scheduleDetails = await CalComAPIService.getScheduleById(scheduleId); } catch (error) { safeLogError("Failed to fetch schedule details:", error); setSelectedScheduleDetails(null); setScheduleDetailsLoading(false); + return; } + setSelectedScheduleDetails(scheduleDetails); + const timeZone = scheduleDetails?.timeZone; + if (timeZone) { + setSelectedTimezone(timeZone); + } + setScheduleDetailsLoading(false); }, []); const fetchSchedules = useCallback(async () => { @@ -408,344 +411,364 @@ export default function EventTypeDetail() { } }, []); + const applyEventTypeData = useCallback((eventType: EventType) => { + setEventTypeData(eventType); + + // Load basic fields + if (eventType.title) setEventTitle(eventType.title); + if (eventType.slug) setEventSlug(eventType.slug); + if (eventType.description) setEventDescription(eventType.description); + if (eventType.lengthInMinutes) setEventDuration(eventType.lengthInMinutes.toString()); + if (eventType.hidden !== undefined) setIsHidden(eventType.hidden); + + const eventTypeExt = eventType as EventType & EventTypeExtended; + const lengthOptions = eventTypeExt.lengthInMinutesOptions; + const hasLengthOptions = + lengthOptions && Array.isArray(lengthOptions) && lengthOptions.length > 0; + if (hasLengthOptions) { + setAllowMultipleDurations(true); + const durationStrings = lengthOptions.map((mins: number) => `${mins} mins`); + setSelectedDurations(durationStrings); + if (eventType.lengthInMinutes) { + setDefaultDuration(`${eventType.lengthInMinutes} mins`); + } + } + + // Load buffer times + if (eventType.beforeEventBuffer) { + setBeforeEventBuffer(`${eventType.beforeEventBuffer} Minutes`); + } + if (eventType.afterEventBuffer) { + setAfterEventBuffer(`${eventType.afterEventBuffer} Minutes`); + } + + // Load minimum booking notice + if (eventType.minimumBookingNotice) { + const minutes = eventType.minimumBookingNotice; + if (minutes >= 1440) { + // Days + setMinimumNoticeValue((minutes / 1440).toString()); + setMinimumNoticeUnit("Days"); + } else if (minutes >= 60) { + // Hours + setMinimumNoticeValue((minutes / 60).toString()); + setMinimumNoticeUnit("Hours"); + } else { + // Minutes + setMinimumNoticeValue(minutes.toString()); + setMinimumNoticeUnit("Minutes"); + } + } + + // Load slot interval + if (eventType.slotInterval) { + setSlotInterval(`${eventType.slotInterval} Minutes`); + } + + // Load booking frequency limits + const bookingLimitsCount = eventType.bookingLimitsCount; + const hasBookingLimitsCount = bookingLimitsCount && !("disabled" in bookingLimitsCount); + if (hasBookingLimitsCount) { + setLimitBookingFrequency(true); + const limits: { id: number; value: string; unit: string }[] = []; + let idCounter = 1; + if (bookingLimitsCount.day) { + limits.push({ + id: idCounter++, + value: bookingLimitsCount.day.toString(), + unit: "Per day", + }); + } + if (bookingLimitsCount.week) { + limits.push({ + id: idCounter++, + value: bookingLimitsCount.week.toString(), + unit: "Per week", + }); + } + if (bookingLimitsCount.month) { + limits.push({ + id: idCounter++, + value: bookingLimitsCount.month.toString(), + unit: "Per month", + }); + } + if (bookingLimitsCount.year) { + limits.push({ + id: idCounter++, + value: bookingLimitsCount.year.toString(), + unit: "Per year", + }); + } + if (limits.length > 0) { + setFrequencyLimits(limits); + } + } + + // Load duration limits + const bookingLimitsDuration = eventType.bookingLimitsDuration; + const hasBookingLimitsDuration = + bookingLimitsDuration && !("disabled" in bookingLimitsDuration); + if (hasBookingLimitsDuration) { + setLimitTotalDuration(true); + const limits: { id: number; value: string; unit: string }[] = []; + let idCounter = 1; + if (bookingLimitsDuration.day) { + limits.push({ + id: idCounter++, + value: bookingLimitsDuration.day.toString(), + unit: "Per day", + }); + } + if (bookingLimitsDuration.week) { + limits.push({ + id: idCounter++, + value: bookingLimitsDuration.week.toString(), + unit: "Per week", + }); + } + if (bookingLimitsDuration.month) { + limits.push({ + id: idCounter++, + value: bookingLimitsDuration.month.toString(), + unit: "Per month", + }); + } + if (bookingLimitsDuration.year) { + limits.push({ + id: idCounter++, + value: bookingLimitsDuration.year.toString(), + unit: "Per year", + }); + } + if (limits.length > 0) { + setDurationLimits(limits); + } + } + + // Load only show first slot + if (eventType.onlyShowFirstAvailableSlot !== undefined) { + setOnlyShowFirstAvailableSlot(eventType.onlyShowFirstAvailableSlot); + } + + if (eventType.bookerActiveBookingsLimit) { + const bookingLimit = eventType.bookerActiveBookingsLimit as BookerActiveBookingsLimitExtended; + const isBookingLimitEnabled = !("disabled" in bookingLimit); + if (isBookingLimitEnabled) { + const maxBookings = bookingLimit.maximumActiveBookings ?? bookingLimit.count; + if (maxBookings !== undefined) { + setMaxActiveBookingsPerBooker(true); + setMaxActiveBookingsValue(maxBookings.toString()); + } + if (bookingLimit.offerReschedule !== undefined) { + setOfferReschedule(bookingLimit.offerReschedule); + } + } + } + + const bookingWindow = eventType.bookingWindow; + const hasBookingWindow = bookingWindow && !("disabled" in bookingWindow); + if (hasBookingWindow) { + setLimitFutureBookings(true); + if (bookingWindow.type === "range") { + setFutureBookingType("range"); + const windowValue = bookingWindow.value; + const isValidRange = Array.isArray(windowValue) && windowValue.length === 2; + if (isValidRange) { + setRangeStartDate(windowValue[0]); + setRangeEndDate(windowValue[1]); + } + } else { + setFutureBookingType("rolling"); + if (typeof bookingWindow.value === "number") { + setRollingDays(bookingWindow.value.toString()); + } + setRollingCalendarDays(bookingWindow.type === "calendarDays"); + } + } + + const metadata = eventType.metadata; + + if (eventTypeExt.disableCancelling !== undefined) { + setDisableCancelling(eventTypeExt.disableCancelling); + } else if (metadata?.disableCancelling) { + setDisableCancelling(true); + } + + if (eventTypeExt.disableRescheduling !== undefined) { + setDisableRescheduling(eventTypeExt.disableRescheduling); + } else if (metadata?.disableRescheduling) { + setDisableRescheduling(true); + } + + if (eventTypeExt.sendCalVideoTranscription !== undefined) { + setSendCalVideoTranscription(eventTypeExt.sendCalVideoTranscription); + } else if (metadata?.sendCalVideoTranscription) { + setSendCalVideoTranscription(true); + } + + if (eventTypeExt.autoTranslate !== undefined) { + setAutoTranslate(eventTypeExt.autoTranslate); + } else if (metadata?.autoTranslate) { + setAutoTranslate(true); + } + + if (metadata) { + const calendarEventNameValue = metadata.calendarEventName; + if (typeof calendarEventNameValue === "string") { + setCalendarEventName(calendarEventNameValue); + } + const addToCalendarEmailValue = metadata.addToCalendarEmail; + if (typeof addToCalendarEmailValue === "string") { + setAddToCalendarEmail(addToCalendarEmailValue); + } + } + + // Load booker layouts + const bookerLayouts = eventType.bookerLayouts; + if (bookerLayouts) { + const enabledLayouts = bookerLayouts.enabledLayouts; + const hasEnabledLayouts = enabledLayouts && Array.isArray(enabledLayouts); + if (hasEnabledLayouts) { + setSelectedLayouts(enabledLayouts); + } + if (bookerLayouts.defaultLayout) { + setDefaultLayout(bookerLayouts.defaultLayout); + } + } + + if (eventType.confirmationPolicy) { + const policy = eventType.confirmationPolicy as ConfirmationPolicyExtended; + const isPolicyEnabled = !("disabled" in policy) || policy.disabled === false; + if (isPolicyEnabled) { + setRequiresConfirmation(true); + } + } + if (eventType.requiresConfirmation !== undefined) { + setRequiresConfirmation(eventType.requiresConfirmation); + } + + if (eventType.requiresBookerEmailVerification !== undefined) { + setRequiresBookerEmailVerification(eventType.requiresBookerEmailVerification); + } + if (eventType.hideCalendarNotes !== undefined) { + setHideCalendarNotes(eventType.hideCalendarNotes); + } + if (eventType.lockTimeZoneToggleOnBookingPage !== undefined) { + setLockTimezone(eventType.lockTimeZoneToggleOnBookingPage); + } + if (eventTypeExt.lockedTimeZone) { + setLockedTimezone(eventTypeExt.lockedTimeZone); + } + if (eventTypeExt.hideCalendarEventDetails !== undefined) { + setHideCalendarEventDetails(eventTypeExt.hideCalendarEventDetails); + } + if (eventTypeExt.hideOrganizerEmail !== undefined) { + setHideOrganizerEmail(eventTypeExt.hideOrganizerEmail); + } + + // Load redirect URL + if (eventType.successRedirectUrl) { + setSuccessRedirectUrl(eventType.successRedirectUrl); + } + if (eventType.forwardParamsSuccessRedirect !== undefined) { + setForwardParamsSuccessRedirect(eventType.forwardParamsSuccessRedirect); + } + + const eventTypeExtColor = eventTypeExt.color; + if (eventTypeExtColor) { + if (eventTypeExtColor.lightThemeHex) { + setEventTypeColorLight(eventTypeExtColor.lightThemeHex); + } + if (eventTypeExtColor.darkThemeHex) { + setEventTypeColorDark(eventTypeExtColor.darkThemeHex); + } + } + const eventTypeColor = eventType.eventTypeColor; + if (eventTypeColor) { + if (eventTypeColor.lightEventTypeColor) { + setEventTypeColorLight(eventTypeColor.lightEventTypeColor); + } + if (eventTypeColor.darkEventTypeColor) { + setEventTypeColorDark(eventTypeColor.darkEventTypeColor); + } + } + + if (eventType.recurrence) { + const recurrence = eventType.recurrence as RecurrenceExtended; + const recurrenceInterval = recurrence.interval; + const recurrenceFrequency = recurrence.frequency; + const isRecurrenceEnabled = + recurrence.disabled !== true && recurrenceInterval && recurrenceFrequency; + if (isRecurrenceEnabled) { + setRecurringEnabled(true); + setRecurringInterval(recurrenceInterval.toString()); + const freq = recurrenceFrequency as "weekly" | "monthly" | "yearly"; + if (freq === "weekly" || freq === "monthly" || freq === "yearly") { + setRecurringFrequency(freq); + } + const occurrences = recurrence.occurrences; + setRecurringOccurrences(occurrences?.toString() || "12"); + } + } + + const locations = eventType.locations; + const hasLocations = locations && locations.length > 0; + if (hasLocations) { + const mappedLocations = locations.map((loc: ApiLocation) => mapApiLocationToItem(loc)); + setLocations(mappedLocations); + + const firstLocation = locations[0]; + if (firstLocation.address) { + setLocationAddress(firstLocation.address); + } + if (firstLocation.link) { + setLocationLink(firstLocation.link); + } + if (firstLocation.phone) { + setLocationPhone(firstLocation.phone); + } + } + + if (eventType.disableGuests !== undefined) { + setDisableGuests(eventType.disableGuests); + } + + if (eventType.seats) { + const seats = eventType.seats as SeatsExtended; + const seatsAreEnabled = + seats.disabled === false || (!("disabled" in seats) && seats.seatsPerTimeSlot); + + if (seatsAreEnabled) { + setSeatsEnabled(true); + if (seats.seatsPerTimeSlot) { + setSeatsPerTimeSlot(seats.seatsPerTimeSlot.toString()); + } + if (seats.showAttendeeInfo !== undefined) { + setShowAttendeeInfo(seats.showAttendeeInfo); + } + if (seats.showAvailabilityCount !== undefined) { + setShowAvailabilityCount(seats.showAvailabilityCount); + } + } + } + }, []); + const fetchEventTypeData = useCallback(async () => { if (!id) return; + let eventType: EventType | null = null; try { - const eventType = await CalComAPIService.getEventTypeById(parseInt(id, 10)); - if (eventType) { - setEventTypeData(eventType); - - // Load basic fields - if (eventType.title) setEventTitle(eventType.title); - if (eventType.slug) setEventSlug(eventType.slug); - if (eventType.description) setEventDescription(eventType.description); - if (eventType.lengthInMinutes) setEventDuration(eventType.lengthInMinutes.toString()); - if (eventType.hidden !== undefined) setIsHidden(eventType.hidden); - - const eventTypeExt = eventType as EventType & EventTypeExtended; - if ( - eventTypeExt.lengthInMinutesOptions && - Array.isArray(eventTypeExt.lengthInMinutesOptions) && - eventTypeExt.lengthInMinutesOptions.length > 0 - ) { - setAllowMultipleDurations(true); - const durationStrings = eventTypeExt.lengthInMinutesOptions.map( - (mins: number) => `${mins} mins` - ); - setSelectedDurations(durationStrings); - if (eventType.lengthInMinutes) { - setDefaultDuration(`${eventType.lengthInMinutes} mins`); - } - } - - // Load buffer times - if (eventType.beforeEventBuffer) { - setBeforeEventBuffer(`${eventType.beforeEventBuffer} Minutes`); - } - if (eventType.afterEventBuffer) { - setAfterEventBuffer(`${eventType.afterEventBuffer} Minutes`); - } - - // Load minimum booking notice - if (eventType.minimumBookingNotice) { - const minutes = eventType.minimumBookingNotice; - if (minutes >= 1440) { - // Days - setMinimumNoticeValue((minutes / 1440).toString()); - setMinimumNoticeUnit("Days"); - } else if (minutes >= 60) { - // Hours - setMinimumNoticeValue((minutes / 60).toString()); - setMinimumNoticeUnit("Hours"); - } else { - // Minutes - setMinimumNoticeValue(minutes.toString()); - setMinimumNoticeUnit("Minutes"); - } - } - - // Load slot interval - if (eventType.slotInterval) { - setSlotInterval(`${eventType.slotInterval} Minutes`); - } - - // Load booking frequency limits - if (eventType.bookingLimitsCount && !("disabled" in eventType.bookingLimitsCount)) { - setLimitBookingFrequency(true); - const limits: { id: number; value: string; unit: string }[] = []; - let idCounter = 1; - if (eventType.bookingLimitsCount.day) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsCount.day.toString(), - unit: "Per day", - }); - } - if (eventType.bookingLimitsCount.week) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsCount.week.toString(), - unit: "Per week", - }); - } - if (eventType.bookingLimitsCount.month) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsCount.month.toString(), - unit: "Per month", - }); - } - if (eventType.bookingLimitsCount.year) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsCount.year.toString(), - unit: "Per year", - }); - } - if (limits.length > 0) { - setFrequencyLimits(limits); - } - } - - // Load duration limits - if (eventType.bookingLimitsDuration && !("disabled" in eventType.bookingLimitsDuration)) { - setLimitTotalDuration(true); - const limits: { id: number; value: string; unit: string }[] = []; - let idCounter = 1; - if (eventType.bookingLimitsDuration.day) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsDuration.day.toString(), - unit: "Per day", - }); - } - if (eventType.bookingLimitsDuration.week) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsDuration.week.toString(), - unit: "Per week", - }); - } - if (eventType.bookingLimitsDuration.month) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsDuration.month.toString(), - unit: "Per month", - }); - } - if (eventType.bookingLimitsDuration.year) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsDuration.year.toString(), - unit: "Per year", - }); - } - if (limits.length > 0) { - setDurationLimits(limits); - } - } - - // Load only show first slot - if (eventType.onlyShowFirstAvailableSlot !== undefined) { - setOnlyShowFirstAvailableSlot(eventType.onlyShowFirstAvailableSlot); - } - - if (eventType.bookerActiveBookingsLimit) { - const bookingLimit = - eventType.bookerActiveBookingsLimit as BookerActiveBookingsLimitExtended; - if (!("disabled" in bookingLimit)) { - const maxBookings = bookingLimit.maximumActiveBookings ?? bookingLimit.count; - if (maxBookings !== undefined) { - setMaxActiveBookingsPerBooker(true); - setMaxActiveBookingsValue(maxBookings.toString()); - } - if (bookingLimit.offerReschedule !== undefined) { - setOfferReschedule(bookingLimit.offerReschedule); - } - } - } - - if (eventType.bookingWindow && !("disabled" in eventType.bookingWindow)) { - setLimitFutureBookings(true); - if (eventType.bookingWindow.type === "range") { - setFutureBookingType("range"); - if ( - Array.isArray(eventType.bookingWindow.value) && - eventType.bookingWindow.value.length === 2 - ) { - setRangeStartDate(eventType.bookingWindow.value[0]); - setRangeEndDate(eventType.bookingWindow.value[1]); - } - } else { - setFutureBookingType("rolling"); - if (typeof eventType.bookingWindow.value === "number") { - setRollingDays(eventType.bookingWindow.value.toString()); - } - setRollingCalendarDays(eventType.bookingWindow.type === "calendarDays"); - } - } - - if (eventTypeExt.disableCancelling !== undefined) { - setDisableCancelling(eventTypeExt.disableCancelling); - } else if (eventType.metadata?.disableCancelling) { - setDisableCancelling(true); - } - - if (eventTypeExt.disableRescheduling !== undefined) { - setDisableRescheduling(eventTypeExt.disableRescheduling); - } else if (eventType.metadata?.disableRescheduling) { - setDisableRescheduling(true); - } - - if (eventTypeExt.sendCalVideoTranscription !== undefined) { - setSendCalVideoTranscription(eventTypeExt.sendCalVideoTranscription); - } else if (eventType.metadata?.sendCalVideoTranscription) { - setSendCalVideoTranscription(true); - } - - if (eventTypeExt.autoTranslate !== undefined) { - setAutoTranslate(eventTypeExt.autoTranslate); - } else if (eventType.metadata?.autoTranslate) { - setAutoTranslate(true); - } - - if (eventType.metadata) { - const calendarEventNameValue = eventType.metadata.calendarEventName; - if (typeof calendarEventNameValue === "string") { - setCalendarEventName(calendarEventNameValue); - } - const addToCalendarEmailValue = eventType.metadata.addToCalendarEmail; - if (typeof addToCalendarEmailValue === "string") { - setAddToCalendarEmail(addToCalendarEmailValue); - } - } - - // Load booker layouts - if (eventType.bookerLayouts) { - if ( - eventType.bookerLayouts.enabledLayouts && - Array.isArray(eventType.bookerLayouts.enabledLayouts) - ) { - setSelectedLayouts(eventType.bookerLayouts.enabledLayouts); - } - if (eventType.bookerLayouts.defaultLayout) { - setDefaultLayout(eventType.bookerLayouts.defaultLayout); - } - } - - if (eventType.confirmationPolicy) { - const policy = eventType.confirmationPolicy as ConfirmationPolicyExtended; - if (!("disabled" in policy) || policy.disabled === false) { - setRequiresConfirmation(true); - } - } - if (eventType.requiresConfirmation !== undefined) { - setRequiresConfirmation(eventType.requiresConfirmation); - } - - if (eventType.requiresBookerEmailVerification !== undefined) { - setRequiresBookerEmailVerification(eventType.requiresBookerEmailVerification); - } - if (eventType.hideCalendarNotes !== undefined) { - setHideCalendarNotes(eventType.hideCalendarNotes); - } - if (eventType.lockTimeZoneToggleOnBookingPage !== undefined) { - setLockTimezone(eventType.lockTimeZoneToggleOnBookingPage); - } - if (eventTypeExt.lockedTimeZone) { - setLockedTimezone(eventTypeExt.lockedTimeZone); - } - if (eventTypeExt.hideCalendarEventDetails !== undefined) { - setHideCalendarEventDetails(eventTypeExt.hideCalendarEventDetails); - } - if (eventTypeExt.hideOrganizerEmail !== undefined) { - setHideOrganizerEmail(eventTypeExt.hideOrganizerEmail); - } - - // Load redirect URL - if (eventType.successRedirectUrl) { - setSuccessRedirectUrl(eventType.successRedirectUrl); - } - if (eventType.forwardParamsSuccessRedirect !== undefined) { - setForwardParamsSuccessRedirect(eventType.forwardParamsSuccessRedirect); - } - - if (eventTypeExt.color) { - if (eventTypeExt.color.lightThemeHex) { - setEventTypeColorLight(eventTypeExt.color.lightThemeHex); - } - if (eventTypeExt.color.darkThemeHex) { - setEventTypeColorDark(eventTypeExt.color.darkThemeHex); - } - } - if (eventType.eventTypeColor) { - if (eventType.eventTypeColor.lightEventTypeColor) { - setEventTypeColorLight(eventType.eventTypeColor.lightEventTypeColor); - } - if (eventType.eventTypeColor.darkEventTypeColor) { - setEventTypeColorDark(eventType.eventTypeColor.darkEventTypeColor); - } - } - - if (eventType.recurrence) { - const recurrence = eventType.recurrence as RecurrenceExtended; - if (recurrence.disabled !== true && recurrence.interval && recurrence.frequency) { - setRecurringEnabled(true); - setRecurringInterval(recurrence.interval.toString()); - const freq = recurrence.frequency as "weekly" | "monthly" | "yearly"; - if (freq === "weekly" || freq === "monthly" || freq === "yearly") { - setRecurringFrequency(freq); - } - setRecurringOccurrences(recurrence.occurrences?.toString() || "12"); - } - } - - if (eventType.locations && eventType.locations.length > 0) { - const mappedLocations = eventType.locations.map((loc: ApiLocation) => - mapApiLocationToItem(loc) - ); - setLocations(mappedLocations); - - const firstLocation = eventType.locations[0]; - if (firstLocation.address) { - setLocationAddress(firstLocation.address); - } - if (firstLocation.link) { - setLocationLink(firstLocation.link); - } - if (firstLocation.phone) { - setLocationPhone(firstLocation.phone); - } - } - - if (eventType.disableGuests !== undefined) { - setDisableGuests(eventType.disableGuests); - } - - if (eventType.seats) { - const seats = eventType.seats as SeatsExtended; - const seatsAreEnabled = - seats.disabled === false || (!("disabled" in seats) && seats.seatsPerTimeSlot); - - if (seatsAreEnabled) { - setSeatsEnabled(true); - if (seats.seatsPerTimeSlot) { - setSeatsPerTimeSlot(seats.seatsPerTimeSlot.toString()); - } - if (seats.showAttendeeInfo !== undefined) { - setShowAttendeeInfo(seats.showAttendeeInfo); - } - if (seats.showAvailabilityCount !== undefined) { - setShowAvailabilityCount(seats.showAvailabilityCount); - } - } - } - } + eventType = await CalComAPIService.getEventTypeById(parseInt(id, 10)); } catch (error) { safeLogError("Failed to fetch event type data:", error); + return; } - }, [id]); + + if (eventType) { + applyEventTypeData(eventType); + } + }, [id, applyEventTypeData]); useEffect(() => { if (activeTab === "availability") { @@ -775,28 +798,35 @@ export default function EventTypeDetail() { }, []); const formatTime = (time: string) => { - try { - // Handle different time formats that might come from the API - let date: Date; + // Handle different time formats that might come from the API + // Extract conditionals outside try/catch for React Compiler + const isColonFormat = time.includes(":"); - if (time.includes(":")) { - // Format like "09:00" or "09:00:00" - const [hours, minutes] = time.split(":").map(Number); - date = new Date(); - date.setHours(hours, minutes || 0, 0, 0); - } else { - // Other formats - date = new Date(time); - } + let date: Date; - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); - } catch { + if (isColonFormat) { + // Format like "09:00" or "09:00:00" + const parts = time.split(":").map(Number); + const hours = parts[0]; + const minutes = parts[1] || 0; + date = new Date(); + date.setHours(hours, minutes, 0, 0); + } else { + // Other formats + date = new Date(time); + } + + // Check if the date is valid + const isValidDate = !Number.isNaN(date.getTime()); + if (!isValidDate) { return time; // Return original if parsing fails } + + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); }; const getDaySchedule = () => { @@ -847,27 +877,30 @@ export default function EventTypeDetail() { }; const handlePreview = async () => { + const eventTypeSlug = eventSlug || "preview"; + let link: string; try { - const eventTypeSlug = eventSlug || "preview"; - const link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); - await openInAppBrowser(link, "event type preview"); + link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); } catch (error) { safeLogError("Failed to generate preview link:", error); showErrorAlert("Error", "Failed to generate preview link. Please try again."); + return; } + await openInAppBrowser(link, "event type preview"); }; const handleCopyLink = async () => { + const eventTypeSlug = eventSlug || "event-link"; + let link: string; try { - const eventTypeSlug = eventSlug || "event-link"; - const link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); - - await Clipboard.setStringAsync(link); - Alert.alert("Success", "Link copied!"); + link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); } catch (error) { safeLogError("Failed to copy link:", error); showErrorAlert("Error", "Failed to copy link. Please try again."); + return; } + await Clipboard.setStringAsync(link); + Alert.alert("Success", "Link copied!"); }; const handleDelete = () => { @@ -934,199 +967,213 @@ export default function EventTypeDetail() { // Detect create vs update mode const isCreateMode = id === "new"; + // Extract values with optional chaining outside try/catch for React Compiler + const selectedScheduleId = selectedSchedule?.id; + setSaving(true); - try { - if (isCreateMode) { - // For CREATE mode, build full payload - const payload: CreateEventTypePayload = { - title: eventTitle, - slug: eventSlug, - lengthInMinutes: durationNum, - }; - if (eventDescription) { - payload.description = eventDescription; - } + if (isCreateMode) { + // For CREATE mode, build full payload + const payload: CreateEventTypePayload = { + title: eventTitle, + slug: eventSlug, + lengthInMinutes: durationNum, + }; - if (locations.length > 0) { - payload.locations = locations.map((loc) => mapItemToApiLocation(loc)); - } - - if (selectedSchedule) { - payload.scheduleId = selectedSchedule.id; - } - - payload.hidden = isHidden; - - // Create new event type - await CalComAPIService.createEventType(payload); - Alert.alert("Success", "Event type created successfully", [ - { - text: "OK", - onPress: () => router.back(), - }, - ]); - setSaving(false); - } else { - // For UPDATE mode, use partial update - only send changed fields - const currentFormState = { - // Basics - eventTitle, - eventSlug, - eventDescription, - eventDuration, - isHidden, - locations, - disableGuests, - - // Multiple durations - allowMultipleDurations, - selectedDurations, - defaultDuration, - - // Availability - selectedScheduleId: selectedSchedule?.id, - - // Limits - beforeEventBuffer, - afterEventBuffer, - minimumNoticeValue, - minimumNoticeUnit, - slotInterval, - limitBookingFrequency, - frequencyLimits, - limitTotalDuration, - durationLimits, - onlyShowFirstAvailableSlot, - maxActiveBookingsPerBooker, - maxActiveBookingsValue, - offerReschedule, - limitFutureBookings, - futureBookingType, - rollingDays, - rollingCalendarDays, - rangeStartDate, - rangeEndDate, - - // Advanced - requiresConfirmation, - requiresBookerEmailVerification, - hideCalendarNotes, - hideCalendarEventDetails, - hideOrganizerEmail, - lockTimezone, - allowReschedulingPastEvents, - allowBookingThroughRescheduleLink, - successRedirectUrl, - forwardParamsSuccessRedirect, - customReplyToEmail, - eventTypeColorLight, - eventTypeColorDark, - calendarEventName, - addToCalendarEmail, - selectedLayouts, - defaultLayout, - disableCancelling, - disableRescheduling, - sendCalVideoTranscription, - autoTranslate, - - // Seats - seatsEnabled, - seatsPerTimeSlot, - showAttendeeInfo, - showAvailabilityCount, - - // Recurring - recurringEnabled, - recurringInterval, - recurringFrequency, - recurringOccurrences, - }; - - // Build partial payload with only changed fields - const payload = buildPartialUpdatePayload(currentFormState, eventTypeData); - - if (Object.keys(payload).length === 0) { - Alert.alert("No Changes", "No changes were made to the event type."); - setSaving(false); - return; - } - - await CalComAPIService.updateEventType(parseInt(id, 10), payload); - Alert.alert("Success", "Event type updated successfully"); - // Refresh event type data to sync with server - await fetchEventTypeData(); - setSaving(false); + if (eventDescription) { + payload.description = eventDescription; } - } catch (error) { - safeLogError("Failed to save event type:", error); - const action = isCreateMode ? "create" : "update"; - showErrorAlert("Error", `Failed to ${action} event type. Please try again.`); + + if (locations.length > 0) { + payload.locations = locations.map((loc) => mapItemToApiLocation(loc)); + } + + if (selectedScheduleId !== undefined) { + payload.scheduleId = selectedScheduleId; + } + + payload.hidden = isHidden; + + // Create new event type + try { + await CalComAPIService.createEventType(payload); + } catch (error) { + safeLogError("Failed to save event type:", error); + showErrorAlert("Error", "Failed to create event type. Please try again."); + setSaving(false); + return; + } + Alert.alert("Success", "Event type created successfully", [ + { + text: "OK", + onPress: () => router.back(), + }, + ]); + setSaving(false); + } else { + // For UPDATE mode, use partial update - only send changed fields + const currentFormState = { + // Basics + eventTitle, + eventSlug, + eventDescription, + eventDuration, + isHidden, + locations, + disableGuests, + + // Multiple durations + allowMultipleDurations, + selectedDurations, + defaultDuration, + + // Availability + selectedScheduleId, + + // Limits + beforeEventBuffer, + afterEventBuffer, + minimumNoticeValue, + minimumNoticeUnit, + slotInterval, + limitBookingFrequency, + frequencyLimits, + limitTotalDuration, + durationLimits, + onlyShowFirstAvailableSlot, + maxActiveBookingsPerBooker, + maxActiveBookingsValue, + offerReschedule, + limitFutureBookings, + futureBookingType, + rollingDays, + rollingCalendarDays, + rangeStartDate, + rangeEndDate, + + // Advanced + requiresConfirmation, + requiresBookerEmailVerification, + hideCalendarNotes, + hideCalendarEventDetails, + hideOrganizerEmail, + lockTimezone, + allowReschedulingPastEvents, + allowBookingThroughRescheduleLink, + successRedirectUrl, + forwardParamsSuccessRedirect, + customReplyToEmail, + eventTypeColorLight, + eventTypeColorDark, + calendarEventName, + addToCalendarEmail, + selectedLayouts, + defaultLayout, + disableCancelling, + disableRescheduling, + sendCalVideoTranscription, + autoTranslate, + + // Seats + seatsEnabled, + seatsPerTimeSlot, + showAttendeeInfo, + showAvailabilityCount, + + // Recurring + recurringEnabled, + recurringInterval, + recurringFrequency, + recurringOccurrences, + }; + + // Build partial payload with only changed fields + const payload = buildPartialUpdatePayload(currentFormState, eventTypeData); + + if (Object.keys(payload).length === 0) { + Alert.alert("No Changes", "No changes were made to the event type."); + setSaving(false); + return; + } + + try { + await CalComAPIService.updateEventType(parseInt(id, 10), payload); + } catch (error) { + safeLogError("Failed to save event type:", error); + showErrorAlert("Error", "Failed to update event type. Please try again."); + setSaving(false); + return; + } + Alert.alert("Success", "Event type updated successfully"); + // Refresh event type data to sync with server + await fetchEventTypeData(); setSaving(false); } }; + const headerTitle = id === "new" ? "Create Event Type" : truncateTitle(title); + const saveButtonText = id === "new" ? "Create" : "Save"; + + const renderHeaderLeft = () => ( + router.back()} className="px-2 py-2"> + + + ); + + const renderHeaderRight = () => ( + + {saveButtonText} + + ); + return ( <> - - - {/* Glass Header */} - - - router.back()} - > - - + - - {id === "new" ? "Create Event Type" : truncateTitle(title)} - + {Platform.OS === "ios" && ( + + + router.back()}> + + + - {headerTitle} + + + - - {id === "new" ? "Create" : "Save"} - - - - + {saveButtonText} + + + + )} + {/* Tabs */} {isLiquidGlassAvailable() ? ( @@ -1164,12 +1211,7 @@ export default function EventTypeDetail() { ) : ( diff --git a/companion/app/mark-no-show.ios.tsx b/companion/app/mark-no-show.ios.tsx new file mode 100644 index 0000000000..f20985e2be --- /dev/null +++ b/companion/app/mark-no-show.ios.tsx @@ -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(null); + const [attendees, setAttendees] = useState([]); + 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 ( + <> + + + + + router.back()}> + + + + + Mark No-Show + + + + {isLoading ? ( + + + + ) : ( + { + 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} + /> + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/mark-no-show.tsx b/companion/app/mark-no-show.tsx similarity index 82% rename from companion/app/(tabs)/(bookings)/mark-no-show.tsx rename to companion/app/mark-no-show.tsx index 46b40756cf..641087fa7d 100644 --- a/companion/app/(tabs)/(bookings)/mark-no-show.tsx +++ b/companion/app/mark-no-show.tsx @@ -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( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + if (isLoading) { return ( <> @@ -68,9 +79,8 @@ export default function MarkNoShow() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Mark No-Show )} @@ -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" && ( - + + + router.back()}> + + + + Mark No-Show )} diff --git a/companion/app/meeting-session-details.ios.tsx b/companion/app/meeting-session-details.ios.tsx new file mode 100644 index 0000000000..5a48ff3ef0 --- /dev/null +++ b/companion/app/meeting-session-details.ios.tsx @@ -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([]); + 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 ( + <> + + + + + router.back()}> + + + + + Session Details + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/meeting-session-details.tsx b/companion/app/meeting-session-details.tsx similarity index 73% rename from companion/app/(tabs)/(bookings)/meeting-session-details.tsx rename to companion/app/meeting-session-details.tsx index 95143f0fcd..ef30fb05f8 100644 --- a/companion/app/(tabs)/(bookings)/meeting-session-details.tsx +++ b/companion/app/meeting-session-details.tsx @@ -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( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + if (isLoading) { return ( <> @@ -40,9 +51,8 @@ export default function MeetingSessionDetails() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Session Details )} @@ -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" && ( - + + + router.back()}> + + + + Session Details )} diff --git a/companion/app/reschedule.ios.tsx b/companion/app/reschedule.ios.tsx new file mode 100644 index 0000000000..7d2d5bee7d --- /dev/null +++ b/companion/app/reschedule.ios.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const rescheduleScreenRef = useRef(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 ( + <> + + + + + router.back()}> + + + + + Reschedule + + + + + + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/reschedule.tsx b/companion/app/reschedule.tsx similarity index 76% rename from companion/app/(tabs)/(bookings)/reschedule.tsx rename to companion/app/reschedule.tsx index b1228543e4..6e671bf5c7 100644 --- a/companion/app/(tabs)/(bookings)/reschedule.tsx +++ b/companion/app/reschedule.tsx @@ -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( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + const renderHeaderRight = useCallback( () => ( - Save + ), [handleSave, isSaving] @@ -64,7 +74,7 @@ export default function Reschedule() { /> {Platform.OS === "ios" && ( - + Reschedule )} @@ -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" && ( - + + + router.back()}> + + + + Reschedule - Save + @@ -103,6 +120,7 @@ export default function Reschedule() { booking={booking} onSuccess={handleRescheduleSuccess} onSavingChange={setIsSaving} + useNativeHeader /> ); diff --git a/companion/app/view-recordings.ios.tsx b/companion/app/view-recordings.ios.tsx new file mode 100644 index 0000000000..174df23b2c --- /dev/null +++ b/companion/app/view-recordings.ios.tsx @@ -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([]); + 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 ( + <> + + + + + router.back()}> + + + + + Recordings + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/view-recordings.tsx b/companion/app/view-recordings.tsx similarity index 73% rename from companion/app/(tabs)/(bookings)/view-recordings.tsx rename to companion/app/view-recordings.tsx index db15984593..a796e98ff3 100644 --- a/companion/app/(tabs)/(bookings)/view-recordings.tsx +++ b/companion/app/view-recordings.tsx @@ -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( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + if (isLoading) { return ( <> @@ -40,9 +51,8 @@ export default function ViewRecordings() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Recordings )} @@ -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" && ( - + + + router.back()}> + + + + Recordings )} diff --git a/companion/biome.json b/companion/biome.json new file mode 100644 index 0000000000..3cd1189706 --- /dev/null +++ b/companion/biome.json @@ -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"] + } + } + ] +} diff --git a/companion/components/LogoutConfirmModal.ios.tsx b/companion/components/LogoutConfirmModal.ios.tsx new file mode 100644 index 0000000000..d05b204050 --- /dev/null +++ b/companion/components/LogoutConfirmModal.ios.tsx @@ -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; +} diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx index 176dd71588..0697958dfb 100644 --- a/companion/components/booking-list-screen/BookingListScreen.tsx +++ b/companion/components/booking-list-screen/BookingListScreen.tsx @@ -123,7 +123,7 @@ export const BookingListScreen: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ contentContainerStyle={{ padding: 20, }} - refreshControl={ - - } + refreshControl={} contentInsetAdjustmentBehavior="automatic" - style={{ backgroundColor: "white" }} - > - + style={{ backgroundColor: "white" }}> + @@ -353,12 +342,9 @@ export const BookingListScreen: React.FC = ({ contentContainerStyle={{ padding: 20, }} - refreshControl={ - - } + refreshControl={} contentInsetAdjustmentBehavior="automatic" - style={{ backgroundColor: "white" }} - > + style={{ backgroundColor: "white" }}> = ({ keyExtractor={(item) => item.key} renderItem={renderListItem} contentContainerStyle={{ paddingBottom: 90 }} - refreshControl={ - - } + refreshControl={} showsVerticalScrollIndicator={false} contentInsetAdjustmentBehavior="automatic" style={{ backgroundColor: "white" }} @@ -393,9 +377,7 @@ export const BookingListScreen: React.FC = ({ keyExtractor={(item) => item.key} renderItem={renderListItem} contentContainerStyle={{ paddingBottom: 90 }} - refreshControl={ - - } + refreshControl={} showsVerticalScrollIndicator={false} /> diff --git a/companion/components/booking-modals/BookingModals.ios.tsx b/companion/components/booking-modals/BookingModals.ios.tsx new file mode 100644 index 0000000000..0a42400b7e --- /dev/null +++ b/companion/components/booking-modals/BookingModals.ios.tsx @@ -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; + + 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 = ({ + 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 ? ( + + + e.stopPropagation()} + className="w-[85%] max-w-[350px] rounded-2xl bg-white p-5" + > + + Filter by Event Type + + + {eventTypesLoading ? ( + + + Loading event types... + + ) : ( + + {eventTypes?.map((eventType) => ( + onEventTypeSelect(eventType.id, eventType.title)} + > + + {eventType.title} + + {selectedEventTypeId === eventType.id ? ( + + ) : null} + + ))} + + {eventTypes?.length === 0 ? ( + + No event types found + + ) : null} + + )} + + + + ) : null} + + { + 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} + /> + + ); +}; diff --git a/companion/components/booking-modals/BookingModals.tsx b/companion/components/booking-modals/BookingModals.tsx index 0846be12a8..37ed7385d0 100644 --- a/companion/components/booking-modals/BookingModals.tsx +++ b/companion/components/booking-modals/BookingModals.tsx @@ -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 = ({ onPress={() => onEventTypeSelect(eventType.id, eventType.title)} > {eventType.title} @@ -272,7 +274,7 @@ export const BookingModals: React.FC = ({ {/* Reject Button */} onRejectSubmit()} disabled={isDeclining} style={{ opacity: isDeclining ? 0.5 : 1 }} > diff --git a/companion/components/screens/AddGuestsScreen.tsx b/companion/components/screens/AddGuestsScreen.tsx index a98f39bae6..ddfd226626 100644 --- a/companion/components/screens/AddGuestsScreen.tsx +++ b/companion/components/screens/AddGuestsScreen.tsx @@ -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( - 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 { + onGuestCountChange?.(guests.length); + }, [guests.length, onGuestCountChange]); + const handleAddGuest = useCallback(() => { const trimmedEmail = email.trim(); if (!trimmedEmail) { @@ -111,7 +123,7 @@ export const AddGuestsScreen = forwardRef + No booking data ); @@ -120,62 +132,70 @@ export const AddGuestsScreen = forwardRef + className={`flex-1 ${backgroundStyle}`}> - {/* Info note */} - - - - Guests will receive an email notification about this booking. - + showsVerticalScrollIndicator={!transparentBackground}> + {/* Email input */} + {transparentBackground && ( + Email * + )} + + {!transparentBackground && ( + Email * + )} + - {/* Form Card */} - - {/* Email input */} - - Email * - - - - {/* Name input */} - + {/* Name input */} + {transparentBackground && ( + Name (optional) + )} + + {!transparentBackground && ( Name (optional) - - + )} + {/* Add button */} + disabled={isSaving}> - Add Guest + Add {/* Guest list */} @@ -184,28 +204,27 @@ export const AddGuestsScreen = forwardRef Guests to add ({guests.length}) - + {guests.map((guest, index) => ( - + index < guests.length - 1 ? "border-b border-gray-100/50" : "" + }`}> + {guest.email} - {guest.name && ( - {guest.name} - )} + {guest.name && {guest.name}} handleRemoveGuest(index)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - disabled={isSaving} - > + disabled={isSaving}> diff --git a/companion/components/screens/AvailabilityDetailScreen.tsx b/companion/components/screens/AvailabilityDetailScreen.tsx index ac8b1c916a..723e5e0f1c 100644 --- a/companion/components/screens/AvailabilityDetailScreen.tsx +++ b/companion/components/screens/AvailabilityDetailScreen.tsx @@ -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 = { + 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>>) => { + 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 = {}; + // Convert availability array to day-indexed object + const availabilityMap: Record = {}; - // Map day names to numbers - const dayNameToNumber: Record = { - 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> = 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 ( - {/* Glass Header */} - - - router.back()} - > - - + {/* Glass Header - only show when not using native header */} + {!useNativeHeader && ( + + + router.back()} + > + + - - Edit Availability - + + Edit Availability + - - Save - - - + + Save + + + + )} @@ -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 }], + }} /> - 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) - - 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) ); -} +}); diff --git a/companion/components/screens/BookingDetailScreen.ios.tsx b/companion/components/screens/BookingDetailScreen.ios.tsx index 966f1cd2cd..9c140f6d0e 100644 --- a/companion/components/screens/BookingDetailScreen.ios.tsx +++ b/companion/components/screens/BookingDetailScreen.ios.tsx @@ -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 */} {/* Meeting Title */} + style={{ letterSpacing: -0.3 }}> {booking.title} @@ -367,9 +376,7 @@ export function BookingDetailScreen({ )} {durationFormatted && ( - - {durationFormatted} - + {durationFormatted} )} @@ -389,8 +396,7 @@ export function BookingDetailScreen({ setParticipantsExpanded(!participantsExpanded)} - > + onPress={() => setParticipantsExpanded(!participantsExpanded)}> Participants {totalParticipants} @@ -410,8 +416,9 @@ export function BookingDetailScreen({ ? booking.hosts.map((host, index) => ( 0 ? "border-t border-[#E5E5EA]" : ""}`} - > + className={`flex-row items-center py-2 ${ + index > 0 ? "border-t border-[#E5E5EA]" : "" + }`}> {host.name || host.email || "Host"} @@ -436,11 +443,8 @@ export function BookingDetailScreen({ 0 || booking.hosts?.length || booking.user - ? "border-t border-[#E5E5EA]" - : "" - }`} - > + index > 0 || booking.hosts?.length || booking.user ? "border-t border-[#E5E5EA]" : "" + }`}> {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]" : "" - }`} - > + }`}> {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; - 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; + 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 ( - 0 ? "border-t border-[#E5E5EA]" : ""}`} - > + 0 ? "border-t border-[#E5E5EA]" : ""}`}> {displayKey} {displayValue} @@ -585,8 +583,7 @@ export function BookingDetailScreen({ params: { uid: fromUid }, }); } - }} - > + }}> Rescheduled from View original @@ -599,12 +596,14 @@ export function BookingDetailScreen({ { - 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 }, + }); + } + }}> Rescheduled to View new @@ -615,8 +614,11 @@ export function BookingDetailScreen({ {booking.reschedulingReason && ( + className={`py-2 ${ + booking.rescheduledFromUid || booking.rescheduledToUid || booking.fromReschedule + ? "border-t border-[#E5E5EA]" + : "" + }`}> Reason {booking.reschedulingReason} @@ -642,9 +644,7 @@ export function BookingDetailScreen({ )} {booking.cancelledByEmail && ( - + Cancelled by {booking.cancelledByEmail} @@ -652,8 +652,11 @@ export function BookingDetailScreen({ {booking.absentHost && ( + className={`flex-row items-center py-2 ${ + booking.cancellationReason || booking.cancelledByEmail + ? "border-t border-[#E5E5EA]" + : "" + }`}> Host was absent diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx index eb213d9175..998af166f4 100644 --- a/companion/components/screens/BookingDetailScreen.tsx +++ b/companion/components/screens/BookingDetailScreen.tsx @@ -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: () => ( setShowActionsModal(true)} - > + onPress={() => setShowActionsModal(true)}> ), @@ -479,9 +478,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen - - {booking.user.name} - + {booking.user.name} host @@ -493,8 +490,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen booking.hosts.map((host, hostIndex) => ( 0 ? "mt-4" : ""}`} - > + className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}> {getInitials(host.name || "Host")} @@ -502,9 +498,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen - - {host.name || "Host"} - + {host.name || "Host"} host @@ -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 ( - 0 ? "mt-4" : ""}`} - > + 0 ? "mt-4" : ""}`}> + }`}> {getInitials(attendee.name)} @@ -541,16 +531,13 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen + }`}> {attendee.name} {isNoShow && ( - - No-show - + No-show )} @@ -570,10 +557,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen Where {locationProvider.url ? ( - + {locationProvider.iconUrl ? ( Recurring Event Every 2 weeks for 6 occurrences @@ -624,19 +607,11 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen {locationProvider?.url ? ( + className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"> {locationProvider.iconUrl ? ( - + ) : null} - - Join {locationProvider.label} - + Join {locationProvider.label} ) : null} @@ -664,9 +639,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen - - Cancelling booking... - + Cancelling booking... )} diff --git a/companion/components/screens/EditLocationScreen.ios.tsx b/companion/components/screens/EditLocationScreen.ios.tsx index 2541553cd9..6a18561917 100644 --- a/companion/components/screens/EditLocationScreen.ios.tsx +++ b/companion/components/screens/EditLocationScreen.ios.tsx @@ -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( - 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("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 + No booking data ); } return ( - + - {/* Current Location Info */} - {booking.location && ( - - - - - - Current Location - - {booking.location} - - - - )} + showsVerticalScrollIndicator={!transparentBackground}> + {transparentBackground ? ( + <> + {/* Location Type Selector with Native Context Menu - Glass UI */} + + + + + + {selectedTypeConfig.label} + {selectedTypeConfig.description} + - {/* Location Type Selector with Native Context Menu */} - - Location Type - - - - - - - + {/* Native iOS Context Menu Button */} + + + +