mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* fix: open Join button in default browser instead of in-app browser Co-Authored-By: [email protected] <[email protected]> * feat(companion): open join in default browser (#27655) --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Dhairyashil Shinde <[email protected]>
531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { Stack, useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { Platform, Text, useColorScheme, View } from "react-native";
|
|
import { AppPressable } from "@/components/AppPressable";
|
|
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
|
|
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import { useBookingByUid } from "@/hooks/useBookings";
|
|
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
|
|
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
|
|
import { getMeetingUrl } from "@/utils/booking";
|
|
import { openInDefaultBrowser } from "@/utils/browser";
|
|
|
|
// Empty actions result for when no booking is loaded
|
|
const EMPTY_ACTIONS: BookingActionsResult = {
|
|
reschedule: { visible: false, enabled: false },
|
|
rescheduleRequest: { visible: false, enabled: false },
|
|
cancel: { visible: false, enabled: false },
|
|
changeLocation: { visible: false, enabled: false },
|
|
addGuests: { visible: false, enabled: false },
|
|
viewRecordings: { visible: false, enabled: false },
|
|
meetingSessionDetails: { visible: false, enabled: false },
|
|
markNoShow: { visible: false, enabled: false },
|
|
};
|
|
|
|
// Type for action handlers exposed by BookingDetailScreen
|
|
type ActionHandlers = {
|
|
openRescheduleModal: () => void;
|
|
openEditLocationModal: () => void;
|
|
openAddGuestsModal: () => void;
|
|
openViewRecordingsModal: () => void;
|
|
openMeetingSessionDetailsModal: () => void;
|
|
openMarkNoShowModal: () => void;
|
|
handleCancelBooking: () => void;
|
|
};
|
|
|
|
const getIconName = (sfSymbol: string): keyof typeof Ionicons.glyphMap => {
|
|
switch (sfSymbol) {
|
|
case "calendar":
|
|
return "calendar";
|
|
case "location":
|
|
return "location";
|
|
case "person.badge.plus":
|
|
return "person-add";
|
|
case "video":
|
|
return "videocam";
|
|
case "info.circle":
|
|
return "information-circle";
|
|
case "eye.slash":
|
|
return "eye-off";
|
|
case "flag":
|
|
return "flag";
|
|
case "xmark.circle":
|
|
return "close-circle";
|
|
default:
|
|
return "ellipsis-horizontal";
|
|
}
|
|
};
|
|
|
|
export default function BookingDetail() {
|
|
"use no memo";
|
|
const { uid } = useLocalSearchParams<{ uid: string }>();
|
|
const { userInfo } = useAuth();
|
|
|
|
// Use React Query hook for booking data - single source of truth
|
|
const { data: booking, isLoading, error, refetch, isRefetching } = useBookingByUid(uid);
|
|
|
|
// Ref to store action handlers from BookingDetailScreen
|
|
const actionHandlersRef = useRef<ActionHandlers | null>(null);
|
|
|
|
// Callback to receive action handlers from BookingDetailScreen
|
|
const handleActionsReady = useCallback((handlers: ActionHandlers) => {
|
|
actionHandlersRef.current = handlers;
|
|
}, []);
|
|
|
|
// Compute actions using centralized gating (same as BookingDetailScreen)
|
|
const actions = useMemo(() => {
|
|
if (!booking) return EMPTY_ACTIONS;
|
|
return getBookingActions({
|
|
booking,
|
|
eventType: undefined, // EventType not available in this context
|
|
currentUserId: userInfo?.id,
|
|
currentUserEmail: userInfo?.email,
|
|
isOnline: true, // Assume online for now
|
|
});
|
|
}, [booking, userInfo?.id, userInfo?.email]);
|
|
|
|
// Action handlers that use the booking data
|
|
// Note: These are stable functions that don't change, so we don't memoize them
|
|
// They access refs which is safe in event handlers
|
|
const handleReschedule = useCallback(() => {
|
|
if (actionHandlersRef.current?.openRescheduleModal) {
|
|
actionHandlersRef.current.openRescheduleModal();
|
|
} else {
|
|
showErrorAlert("Error", "Unable to reschedule. Please try again.");
|
|
}
|
|
}, []);
|
|
|
|
const handleEditLocation = useCallback(() => {
|
|
if (actionHandlersRef.current?.openEditLocationModal) {
|
|
actionHandlersRef.current.openEditLocationModal();
|
|
} else {
|
|
showErrorAlert("Error", "Unable to edit location. Please try again.");
|
|
}
|
|
}, []);
|
|
|
|
const handleAddGuests = useCallback(() => {
|
|
if (actionHandlersRef.current?.openAddGuestsModal) {
|
|
actionHandlersRef.current.openAddGuestsModal();
|
|
} else {
|
|
showErrorAlert("Error", "Unable to add guests. Please try again.");
|
|
}
|
|
}, []);
|
|
|
|
const handleViewRecordings = useCallback(() => {
|
|
if (actionHandlersRef.current?.openViewRecordingsModal) {
|
|
actionHandlersRef.current.openViewRecordingsModal();
|
|
} else {
|
|
showErrorAlert("Error", "Unable to view recordings. Please try again.");
|
|
}
|
|
}, []);
|
|
|
|
const handleSessionDetails = useCallback(() => {
|
|
if (actionHandlersRef.current?.openMeetingSessionDetailsModal) {
|
|
actionHandlersRef.current.openMeetingSessionDetailsModal();
|
|
} else {
|
|
showErrorAlert("Error", "Unable to view session details. Please try again.");
|
|
}
|
|
}, []);
|
|
|
|
const handleMarkNoShow = useCallback(() => {
|
|
if (actionHandlersRef.current?.openMarkNoShowModal) {
|
|
actionHandlersRef.current.openMarkNoShowModal();
|
|
} else {
|
|
showErrorAlert("Error", "Unable to mark no-show. Please try again.");
|
|
}
|
|
}, []);
|
|
|
|
const handleReport = useCallback(() => {
|
|
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
|
|
}, []);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
if (actionHandlersRef.current?.handleCancelBooking) {
|
|
actionHandlersRef.current.handleCancelBooking();
|
|
} else {
|
|
showErrorAlert("Error", "Unable to cancel. Please try again.");
|
|
}
|
|
}, []);
|
|
|
|
// Define booking actions organized by sections
|
|
// For iOS glass UI (header menu), we filter out actions where !visible || !enabled
|
|
// This matches the behavior in BookingListItem.ios.tsx context menu
|
|
const bookingActionsSections = useMemo(() => {
|
|
// All possible actions with their gating keys
|
|
const allEditEventActions = [
|
|
{
|
|
id: "reschedule",
|
|
label: "Reschedule Booking",
|
|
icon: "calendar" as const,
|
|
onPress: handleReschedule,
|
|
gatingKey: "reschedule" as const,
|
|
},
|
|
{
|
|
id: "edit-location",
|
|
label: "Edit Location",
|
|
icon: "location" as const,
|
|
onPress: handleEditLocation,
|
|
gatingKey: "changeLocation" as const,
|
|
},
|
|
{
|
|
id: "add-guests",
|
|
label: "Add Guests",
|
|
icon: "person.badge.plus" as const,
|
|
onPress: handleAddGuests,
|
|
gatingKey: "addGuests" as const,
|
|
},
|
|
];
|
|
|
|
const allAfterEventActions = [
|
|
{
|
|
id: "view-recordings",
|
|
label: "View Recordings",
|
|
icon: "video" as const,
|
|
onPress: handleViewRecordings,
|
|
gatingKey: "viewRecordings" as const,
|
|
},
|
|
{
|
|
id: "session-details",
|
|
label: "Meeting Session Details",
|
|
icon: "info.circle" as const,
|
|
onPress: handleSessionDetails,
|
|
gatingKey: "meetingSessionDetails" as const,
|
|
},
|
|
{
|
|
id: "mark-no-show",
|
|
label: "Mark as No-Show",
|
|
icon: "eye.slash" as const,
|
|
onPress: handleMarkNoShow,
|
|
gatingKey: "markNoShow" as const,
|
|
},
|
|
];
|
|
|
|
const allStandaloneActions = [
|
|
{
|
|
id: "report",
|
|
label: "Report Booking",
|
|
icon: "flag" as const,
|
|
onPress: handleReport,
|
|
destructive: true,
|
|
// Report is always visible (no gating)
|
|
gatingKey: null,
|
|
},
|
|
{
|
|
id: "cancel",
|
|
label: "Cancel Event",
|
|
icon: "xmark.circle" as const,
|
|
onPress: handleCancel,
|
|
destructive: true,
|
|
gatingKey: "cancel" as const,
|
|
},
|
|
];
|
|
|
|
// Filter actions based on gating (for glass UI, only show visible && enabled)
|
|
const filterAction = (action: { gatingKey: keyof typeof actions | null }) => {
|
|
if (action.gatingKey === null) return true; // No gating, always show
|
|
const gating = actions[action.gatingKey];
|
|
return gating.visible && gating.enabled;
|
|
};
|
|
|
|
return {
|
|
editEvent: allEditEventActions.filter(filterAction),
|
|
afterEvent: allAfterEventActions.filter(filterAction),
|
|
standalone: allStandaloneActions.filter(filterAction),
|
|
};
|
|
}, [
|
|
actions,
|
|
handleReschedule,
|
|
handleEditLocation,
|
|
handleAddGuests,
|
|
handleViewRecordings,
|
|
handleSessionDetails,
|
|
handleMarkNoShow,
|
|
handleReport,
|
|
handleCancel,
|
|
]);
|
|
|
|
const router = useRouter();
|
|
const navigation = useNavigation();
|
|
const colorScheme = useColorScheme();
|
|
const isDarkMode = colorScheme === "dark";
|
|
|
|
const meetingUrl = useMemo(() => getMeetingUrl(booking ?? null), [booking]);
|
|
|
|
const handleJoinMeeting = useCallback(() => {
|
|
if (meetingUrl) {
|
|
openInDefaultBrowser(meetingUrl, "meeting link");
|
|
}
|
|
}, [meetingUrl]);
|
|
|
|
const handleCopyMeetingLink = useCallback(async () => {
|
|
if (meetingUrl) {
|
|
await Clipboard.setStringAsync(meetingUrl);
|
|
showSuccessAlert("Copied", "Meeting link copied to clipboard");
|
|
}
|
|
}, [meetingUrl]);
|
|
|
|
const renderHeaderLeft = useCallback(() => {
|
|
let backButtonLabel = "Back";
|
|
|
|
// Check both start and startTime properties
|
|
const startTime = booking?.start || booking?.startTime;
|
|
|
|
if (startTime) {
|
|
const date = new Date(startTime);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
backButtonLabel = date.toLocaleDateString("en-US", { month: "long" });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<HeaderButtonWrapper side="left">
|
|
<AppPressable
|
|
onPress={() => router.back()}
|
|
className="mr-2 h-10 flex-row items-center justify-center rounded-full border border-[#E5E5E5] bg-white px-3 dark:border-[#262626] dark:bg-[#171717]"
|
|
>
|
|
<Ionicons
|
|
name="chevron-back"
|
|
size={20}
|
|
color={isDarkMode ? "#FFFFFF" : "#000000"}
|
|
style={{ marginRight: 4 }}
|
|
/>
|
|
<Text className={`text-[15px] font-medium ${isDarkMode ? "text-white" : "text-black"}`}>
|
|
{backButtonLabel}
|
|
</Text>
|
|
</AppPressable>
|
|
</HeaderButtonWrapper>
|
|
);
|
|
}, [booking?.start, booking?.startTime, router, isDarkMode]);
|
|
|
|
const renderHeaderRight = useCallback(
|
|
() => (
|
|
<HeaderButtonWrapper side="right">
|
|
<View className="flex-row items-center gap-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<AppPressable className="h-10 w-10 items-center justify-center rounded-full border border-[#E5E5E5] bg-white dark:border-[#262626] dark:bg-[#171717]">
|
|
<Ionicons
|
|
name="ellipsis-horizontal"
|
|
size={20}
|
|
color={isDarkMode ? "#FFFFFF" : "#000000"}
|
|
/>
|
|
</AppPressable>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
|
|
sideOffset={8}
|
|
className="w-56"
|
|
align="end"
|
|
>
|
|
{meetingUrl && (
|
|
<>
|
|
<DropdownMenuItem onPress={handleCopyMeetingLink}>
|
|
<View className="flex-row items-center gap-2">
|
|
<Ionicons
|
|
name="copy-outline"
|
|
size={18}
|
|
color={isDarkMode ? "#FFFFFF" : "#000000"}
|
|
/>
|
|
<Text className="text-base text-[#000000] dark:text-white">
|
|
Copy meeting link
|
|
</Text>
|
|
</View>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
|
|
{bookingActionsSections.editEvent.length > 0 && (
|
|
<>
|
|
<DropdownMenuLabel>Edit Event</DropdownMenuLabel>
|
|
{bookingActionsSections.editEvent.map((action) => (
|
|
<DropdownMenuItem key={action.id} onPress={action.onPress}>
|
|
<View className="flex-row items-center gap-2">
|
|
<Ionicons
|
|
name={getIconName(action.icon)}
|
|
size={18}
|
|
color={isDarkMode ? "#FFFFFF" : "#000000"}
|
|
/>
|
|
<Text className="text-base text-[#000000] dark:text-white">
|
|
{action.label}
|
|
</Text>
|
|
</View>
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
|
|
{bookingActionsSections.afterEvent.length > 0 && (
|
|
<>
|
|
<DropdownMenuLabel>After Event</DropdownMenuLabel>
|
|
{bookingActionsSections.afterEvent.map((action) => (
|
|
<DropdownMenuItem key={action.id} onPress={action.onPress}>
|
|
<View className="flex-row items-center gap-2">
|
|
<Ionicons
|
|
name={getIconName(action.icon)}
|
|
size={18}
|
|
color={isDarkMode ? "#FFFFFF" : "#000000"}
|
|
/>
|
|
<Text className="text-base text-[#000000] dark:text-white">
|
|
{action.label}
|
|
</Text>
|
|
</View>
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
|
|
<DropdownMenuLabel>Danger Zone</DropdownMenuLabel>
|
|
{bookingActionsSections.standalone.map((action) => (
|
|
<DropdownMenuItem
|
|
key={action.id}
|
|
onPress={action.onPress}
|
|
variant={action.destructive ? "destructive" : "default"}
|
|
>
|
|
<View className="flex-row items-center gap-2">
|
|
<Ionicons
|
|
name={getIconName(action.icon)}
|
|
size={18}
|
|
color={action.destructive ? "#EF4444" : isDarkMode ? "#FFFFFF" : "#000000"}
|
|
/>
|
|
<Text
|
|
className={`text-base ${
|
|
action.destructive ? "text-red-500" : "text-[#000000] dark:text-white"
|
|
}`}
|
|
>
|
|
{action.label}
|
|
</Text>
|
|
</View>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{meetingUrl && (
|
|
<AppPressable
|
|
onPress={handleJoinMeeting}
|
|
className={`h-9 flex-row items-center justify-center rounded-full border px-5 ${
|
|
isDarkMode ? "border-white bg-white" : "border-black bg-black"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-[15px] font-medium ${isDarkMode ? "text-black" : "text-white"}`}
|
|
>
|
|
Join
|
|
</Text>
|
|
</AppPressable>
|
|
)}
|
|
</View>
|
|
</HeaderButtonWrapper>
|
|
),
|
|
[isDarkMode, meetingUrl, handleCopyMeetingLink, bookingActionsSections, handleJoinMeeting]
|
|
);
|
|
|
|
// Force header update on Android/Web
|
|
useEffect(() => {
|
|
if (Platform.OS === "android" || Platform.OS === "web") {
|
|
navigation.setOptions({
|
|
headerRight: renderHeaderRight,
|
|
headerLeft: renderHeaderLeft,
|
|
});
|
|
}
|
|
}, [navigation, renderHeaderRight, renderHeaderLeft]);
|
|
|
|
return (
|
|
<>
|
|
<Stack.Screen
|
|
options={{
|
|
headerBackButtonDisplayMode: "minimal",
|
|
title: "",
|
|
headerShown: Platform.OS !== "ios",
|
|
headerStyle: {
|
|
backgroundColor: isDarkMode ? "black" : "white",
|
|
},
|
|
headerShadowVisible: false,
|
|
headerLeft:
|
|
Platform.OS === "android" || Platform.OS === "web" ? renderHeaderLeft : undefined,
|
|
headerRight:
|
|
Platform.OS === "android" || Platform.OS === "web" ? renderHeaderRight : undefined,
|
|
}}
|
|
/>
|
|
|
|
{Platform.OS === "ios" && (
|
|
<Stack.Header style={{ shadowColor: "transparent" }}>
|
|
<Stack.Header.Title>Booking Details</Stack.Header.Title>
|
|
|
|
{/* Header right and left API only works on iOS ATM see: https://docs.expo.dev/versions/unversioned/sdk/router/#stackheaderright */}
|
|
<Stack.Header.Right>
|
|
<Stack.Header.Menu>
|
|
<Stack.Header.Label>Actions</Stack.Header.Label>
|
|
<Stack.Header.Icon sf="ellipsis" />
|
|
|
|
{/* Edit Event Section */}
|
|
<Stack.Header.Menu inline title="Edit Event">
|
|
{bookingActionsSections.editEvent.map((action) => (
|
|
<Stack.Header.MenuAction
|
|
key={action.id}
|
|
icon={action.icon}
|
|
onPress={action.onPress}
|
|
>
|
|
{action.label}
|
|
</Stack.Header.MenuAction>
|
|
))}
|
|
</Stack.Header.Menu>
|
|
|
|
{/* After Event Section */}
|
|
<Stack.Header.Menu inline title="After Event">
|
|
{bookingActionsSections.afterEvent.map((action) => (
|
|
<Stack.Header.MenuAction
|
|
key={action.id}
|
|
icon={action.icon}
|
|
onPress={action.onPress}
|
|
>
|
|
{action.label}
|
|
</Stack.Header.MenuAction>
|
|
))}
|
|
</Stack.Header.Menu>
|
|
|
|
{/* Danger Zone Submenu */}
|
|
<Stack.Header.Menu inline title="Danger Zone">
|
|
{bookingActionsSections.standalone.map((action) => (
|
|
<Stack.Header.MenuAction
|
|
key={action.id}
|
|
icon={action.icon}
|
|
onPress={action.onPress}
|
|
destructive
|
|
>
|
|
{action.label}
|
|
</Stack.Header.MenuAction>
|
|
))}
|
|
</Stack.Header.Menu>
|
|
</Stack.Header.Menu>
|
|
</Stack.Header.Right>
|
|
</Stack.Header>
|
|
)}
|
|
|
|
<BookingDetailScreen
|
|
booking={booking}
|
|
isLoading={isLoading}
|
|
error={error ?? null}
|
|
refetch={refetch}
|
|
isRefetching={isRefetching}
|
|
onActionsReady={handleActionsReady}
|
|
/>
|
|
|
|
{/* Action Modals for iOS header menu */}
|
|
</>
|
|
);
|
|
}
|