feat(companion): implement comprehensive booking actions system (#26185)
This commit is contained in:
@@ -6,6 +6,12 @@ export default function BookingsLayout() {
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: Platform.OS !== "ios" ? false : true }} />
|
||||
<Stack.Screen name="booking-detail" />
|
||||
<Stack.Screen name="reschedule" />
|
||||
<Stack.Screen name="edit-location" />
|
||||
<Stack.Screen name="add-guests" />
|
||||
<Stack.Screen name="mark-no-show" />
|
||||
<Stack.Screen name="view-recordings" />
|
||||
<Stack.Screen name="meeting-session-details" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { AppPressable } from "../../../components/AppPressable";
|
||||
import AddGuestsScreen from "../../../components/screens/AddGuestsScreen";
|
||||
import type { AddGuestsScreenHandle } from "../../../components/screens/AddGuestsScreen";
|
||||
import { CalComAPIService, type Booking } from "../../../services/calcom";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Alert, ActivityIndicator, View, Text, Platform } from "react-native";
|
||||
|
||||
export default function AddGuests() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const addGuestsScreenRef = useRef<AddGuestsScreenHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
addGuestsScreenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleAddGuestsSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const renderHeaderRight = useCallback(
|
||||
() => (
|
||||
<AppPressable
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
className={`px-4 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className="text-[16px] font-semibold text-[#007AFF]">Save</Text>
|
||||
</AppPressable>
|
||||
),
|
||||
[handleSave, isSaving]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Add Guests",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Add Guests</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Add Guests",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Add Guests</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
|
||||
Save
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<AddGuestsScreen
|
||||
ref={addGuestsScreenRef}
|
||||
booking={booking}
|
||||
onSuccess={handleAddGuestsSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +1,238 @@
|
||||
import { BookingDetailScreen } from "../../../components/screens/BookingDetailScreen";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
import { useBookingActionModals } from "../../../hooks";
|
||||
import { CalComAPIService, type Booking } from "../../../services/calcom";
|
||||
import { getBookingActions, type BookingActionsResult } from "../../../utils/booking-actions";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
export default function BookingDetail() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const { userInfo } = useAuth();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
|
||||
if (!uid) {
|
||||
return null;
|
||||
}
|
||||
// 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;
|
||||
}, []);
|
||||
|
||||
// Fetch booking data for the iOS header menu actions
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
// Error handling is done in BookingDetailScreen
|
||||
});
|
||||
}
|
||||
}, [uid]);
|
||||
|
||||
// Booking action modals hook
|
||||
const { selectedBooking: actionModalBooking } = useBookingActionModals();
|
||||
|
||||
// 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
|
||||
const handleReschedule = useCallback(() => {
|
||||
// Use the action handler from BookingDetailScreen if available
|
||||
if (actionHandlersRef.current?.openRescheduleModal) {
|
||||
actionHandlersRef.current.openRescheduleModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to reschedule. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEditLocation = useCallback(() => {
|
||||
// Use the action handler from BookingDetailScreen if available
|
||||
if (actionHandlersRef.current?.openEditLocationModal) {
|
||||
actionHandlersRef.current.openEditLocationModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to edit location. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddGuests = useCallback(() => {
|
||||
// Use the action handler from BookingDetailScreen if available
|
||||
if (actionHandlersRef.current?.openAddGuestsModal) {
|
||||
actionHandlersRef.current.openAddGuestsModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to add guests. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleViewRecordings = useCallback(() => {
|
||||
// Use the action handler from BookingDetailScreen if available
|
||||
if (actionHandlersRef.current?.openViewRecordingsModal) {
|
||||
actionHandlersRef.current.openViewRecordingsModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to view recordings. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSessionDetails = useCallback(() => {
|
||||
// Use the action handler from BookingDetailScreen if available
|
||||
if (actionHandlersRef.current?.openMeetingSessionDetailsModal) {
|
||||
actionHandlersRef.current.openMeetingSessionDetailsModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to view session details. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMarkNoShow = useCallback(() => {
|
||||
// Use the action handler from BookingDetailScreen if available
|
||||
if (actionHandlersRef.current?.openMarkNoShowModal) {
|
||||
actionHandlersRef.current.openMarkNoShowModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to mark no-show. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
// Use the action handler from BookingDetailScreen if available
|
||||
if (actionHandlersRef.current?.handleCancelBooking) {
|
||||
actionHandlersRef.current.handleCancelBooking();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to cancel. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Define booking actions organized by sections
|
||||
const bookingActionsSections = {
|
||||
editEvent: [
|
||||
// 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",
|
||||
onPress: () => Alert.alert("Reschedule", "Reschedule functionality coming soon"),
|
||||
icon: "calendar" as const,
|
||||
onPress: handleReschedule,
|
||||
gatingKey: "reschedule" as const,
|
||||
},
|
||||
{
|
||||
id: "edit-location",
|
||||
label: "Edit Location",
|
||||
icon: "location",
|
||||
onPress: () => Alert.alert("Edit Location", "Edit location functionality coming soon"),
|
||||
icon: "location" as const,
|
||||
onPress: handleEditLocation,
|
||||
gatingKey: "changeLocation" as const,
|
||||
},
|
||||
{
|
||||
id: "add-guests",
|
||||
label: "Add Guests",
|
||||
icon: "person.badge.plus",
|
||||
onPress: () => Alert.alert("Add Guests", "Add guests functionality coming soon"),
|
||||
icon: "person.badge.plus" as const,
|
||||
onPress: handleAddGuests,
|
||||
gatingKey: "addGuests" as const,
|
||||
},
|
||||
],
|
||||
afterEvent: [
|
||||
];
|
||||
|
||||
const allAfterEventActions = [
|
||||
{
|
||||
id: "view-recordings",
|
||||
label: "View Recordings",
|
||||
icon: "video",
|
||||
onPress: () => Alert.alert("View Recordings", "View recordings functionality coming soon"),
|
||||
icon: "video" as const,
|
||||
onPress: handleViewRecordings,
|
||||
gatingKey: "viewRecordings" as const,
|
||||
},
|
||||
{
|
||||
id: "session-details",
|
||||
label: "Meeting Session Details",
|
||||
icon: "info.circle",
|
||||
onPress: () =>
|
||||
Alert.alert("Session Details", "Meeting session details functionality coming soon"),
|
||||
icon: "info.circle" as const,
|
||||
onPress: handleSessionDetails,
|
||||
gatingKey: "meetingSessionDetails" as const,
|
||||
},
|
||||
{
|
||||
id: "mark-no-show",
|
||||
label: "Mark as No-Show",
|
||||
icon: "eye.slash",
|
||||
onPress: () => Alert.alert("Mark No-Show", "Mark as no-show functionality coming soon"),
|
||||
icon: "eye.slash" as const,
|
||||
onPress: handleMarkNoShow,
|
||||
gatingKey: "markNoShow" as const,
|
||||
},
|
||||
],
|
||||
standalone: [
|
||||
];
|
||||
|
||||
const allStandaloneActions = [
|
||||
{
|
||||
id: "report",
|
||||
label: "Report Booking",
|
||||
icon: "flag",
|
||||
onPress: () => Alert.alert("Report", "Report booking functionality coming soon"),
|
||||
icon: "flag" as const,
|
||||
onPress: handleReport,
|
||||
destructive: true,
|
||||
// Report is always visible (no gating)
|
||||
gatingKey: null,
|
||||
},
|
||||
{
|
||||
id: "cancel",
|
||||
label: "Cancel Event",
|
||||
icon: "xmark.circle",
|
||||
onPress: () => Alert.alert("Cancel Event", "Cancel event functionality coming soon"),
|
||||
icon: "xmark.circle" as const,
|
||||
onPress: handleCancel,
|
||||
destructive: true,
|
||||
gatingKey: "cancel" as const,
|
||||
},
|
||||
],
|
||||
} 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,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -131,7 +294,9 @@ export default function BookingDetail() {
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
<BookingDetailScreen uid={uid} />
|
||||
<BookingDetailScreen uid={uid} onActionsReady={handleActionsReady} />
|
||||
|
||||
{/* Action Modals for iOS header menu */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { AppPressable } from "../../../components/AppPressable";
|
||||
import EditLocationScreen from "../../../components/screens/EditLocationScreen";
|
||||
import type { EditLocationScreenHandle } from "../../../components/screens/EditLocationScreen";
|
||||
import { CalComAPIService, type Booking } from "../../../services/calcom";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Alert, ActivityIndicator, View, Text, Platform } from "react-native";
|
||||
|
||||
export default function EditLocation() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const editLocationScreenRef = useRef<EditLocationScreenHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
editLocationScreenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleUpdateSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const renderHeaderRight = useCallback(
|
||||
() => (
|
||||
<AppPressable
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
className={`px-4 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className="text-[16px] font-semibold text-[#007AFF]">Save</Text>
|
||||
</AppPressable>
|
||||
),
|
||||
[handleSave, isSaving]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Edit Location",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Edit Location</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Edit Location",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Edit Location</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
|
||||
Save
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<EditLocationScreen
|
||||
ref={editLocationScreenRef}
|
||||
booking={booking}
|
||||
onSuccess={handleUpdateSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import MarkNoShowScreen from "../../../components/screens/MarkNoShowScreen";
|
||||
import { CalComAPIService, type Booking } from "../../../services/calcom";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Alert, ActivityIndicator, View, Platform } from "react-native";
|
||||
|
||||
interface Attendee {
|
||||
id?: number | string;
|
||||
email: string;
|
||||
name: string;
|
||||
noShow?: boolean;
|
||||
}
|
||||
|
||||
export default function MarkNoShow() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then((bookingData) => {
|
||||
setBooking(bookingData);
|
||||
// API may return "absent" or "noShow" depending on endpoint version
|
||||
const bookingAttendees: Attendee[] = [];
|
||||
if (bookingData.attendees && Array.isArray(bookingData.attendees)) {
|
||||
bookingData.attendees.forEach((att: any) => {
|
||||
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]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Mark No-Show",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Mark No-Show</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Mark No-Show",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header with native styling */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Mark No-Show</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<MarkNoShowScreen
|
||||
booking={booking}
|
||||
attendees={attendees}
|
||||
onUpdate={setAttendees}
|
||||
onBookingUpdate={(updatedBooking) => {
|
||||
setBooking(updatedBooking);
|
||||
const updatedAttendees: Attendee[] = [];
|
||||
if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
|
||||
updatedBooking.attendees.forEach((att: any) => {
|
||||
updatedAttendees.push({
|
||||
id: att.id,
|
||||
email: att.email,
|
||||
name: att.name || att.email,
|
||||
noShow: att.absent === true || att.noShow === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
setAttendees(updatedAttendees);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import MeetingSessionDetailsScreen from "../../../components/screens/MeetingSessionDetailsScreen";
|
||||
import { CalComAPIService, type Booking } from "../../../services/calcom";
|
||||
import type { ConferencingSession } from "../../../services/types/bookings.types";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Alert, ActivityIndicator, View, Platform } from "react-native";
|
||||
|
||||
export default function MeetingSessionDetails() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [sessions, setSessions] = useState<ConferencingSession[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
Promise.all([
|
||||
CalComAPIService.getBookingByUid(uid),
|
||||
CalComAPIService.getConferencingSessions(uid).catch((error) => {
|
||||
console.error("Failed to load sessions:", error);
|
||||
return [];
|
||||
}),
|
||||
])
|
||||
.then(([bookingData, sessionsData]) => {
|
||||
setBooking(bookingData);
|
||||
setSessions(sessionsData);
|
||||
})
|
||||
.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]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Session Details",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Session Details</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Session Details",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header with native styling */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Session Details</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<MeetingSessionDetailsScreen sessions={sessions} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { AppPressable } from "../../../components/AppPressable";
|
||||
import RescheduleScreen from "../../../components/screens/RescheduleScreen";
|
||||
import type { RescheduleScreenHandle } from "../../../components/screens/RescheduleScreen";
|
||||
import { CalComAPIService, type Booking } from "../../../services/calcom";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Alert, ActivityIndicator, View, Text, Platform } from "react-native";
|
||||
|
||||
export default function Reschedule() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const rescheduleScreenRef = useRef<RescheduleScreenHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
rescheduleScreenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleRescheduleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const renderHeaderRight = useCallback(
|
||||
() => (
|
||||
<AppPressable
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
className={`px-4 py-2 ${isSaving ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className="text-[16px] font-semibold text-[#007AFF]">Save</Text>
|
||||
</AppPressable>
|
||||
),
|
||||
[handleSave, isSaving]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Reschedule",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Reschedule</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Reschedule",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Reschedule</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
|
||||
Save
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<RescheduleScreen
|
||||
ref={rescheduleScreenRef}
|
||||
booking={booking}
|
||||
onSuccess={handleRescheduleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import ViewRecordingsScreen from "../../../components/screens/ViewRecordingsScreen";
|
||||
import { CalComAPIService } from "../../../services/calcom";
|
||||
import type { BookingRecording } from "../../../services/types/bookings.types";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Alert, ActivityIndicator, View, Platform } from "react-native";
|
||||
|
||||
export default function ViewRecordings() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
const router = useRouter();
|
||||
const [recordings, setRecordings] = useState<BookingRecording[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getRecordings(uid)
|
||||
.then(setRecordings)
|
||||
.catch((error) => {
|
||||
console.error("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]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Recordings",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Recordings</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Recordings",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS-only Stack.Header with native styling */}
|
||||
{Platform.OS === "ios" && (
|
||||
<Stack.Header style={{ shadowColor: "transparent" }}>
|
||||
<Stack.Header.Title>Recordings</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
)}
|
||||
|
||||
<ViewRecordingsScreen recordings={recordings} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import React, { useState, useMemo, Activity } from "react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -397,9 +397,7 @@ export default function EventTypes() {
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
</Activity>
|
||||
{Platform.OS === "web" && <Header />}
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<LoadingSpinner size="large" />
|
||||
</View>
|
||||
@@ -410,9 +408,7 @@ export default function EventTypes() {
|
||||
if (error) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
</Activity>
|
||||
{Platform.OS === "web" && <Header />}
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
|
||||
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
|
||||
@@ -430,9 +426,7 @@ export default function EventTypes() {
|
||||
if (eventTypes.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
</Activity>
|
||||
{Platform.OS === "web" && <Header />}
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<EmptyScreen
|
||||
icon="link-outline"
|
||||
@@ -449,28 +443,30 @@ export default function EventTypes() {
|
||||
if (filteredEventTypes.length === 0 && searchQuery.trim() !== "") {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-100">
|
||||
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search event types"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Activity>
|
||||
{Platform.OS === "web" && (
|
||||
<>
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search event types"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<EmptyScreen
|
||||
icon="search-outline"
|
||||
@@ -502,28 +498,30 @@ export default function EventTypes() {
|
||||
barTintColor="#fff"
|
||||
/>
|
||||
</Stack.Header>
|
||||
<Activity mode={Platform.OS === "web" || Platform.OS === "android" ? "visible" : "hidden"}>
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search event types"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Activity>
|
||||
{(Platform.OS === "web" || Platform.OS === "android") && (
|
||||
<>
|
||||
<Header />
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="Search event types"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
style={{ backgroundColor: "white" }}
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function More() {
|
||||
{menuItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.name}
|
||||
onPress={item.href ? () => router.push(item.href!) : item.onPress}
|
||||
onPress={item.onPress}
|
||||
className={`flex-row items-center justify-between bg-white px-5 py-5 active:bg-[#F8F9FA] ${
|
||||
index < menuItems.length - 1 ? "border-b border-[#E5E5EA]" : ""
|
||||
}`}
|
||||
|
||||
@@ -560,11 +560,13 @@ export default function EventTypeDetail() {
|
||||
}
|
||||
|
||||
if (eventType.metadata) {
|
||||
if (eventType.metadata.calendarEventName) {
|
||||
setCalendarEventName(eventType.metadata.calendarEventName);
|
||||
const calendarEventNameValue = eventType.metadata.calendarEventName;
|
||||
if (typeof calendarEventNameValue === "string") {
|
||||
setCalendarEventName(calendarEventNameValue);
|
||||
}
|
||||
if (eventType.metadata.addToCalendarEmail) {
|
||||
setAddToCalendarEmail(eventType.metadata.addToCalendarEmail);
|
||||
const addToCalendarEmailValue = eventType.metadata.addToCalendarEmail;
|
||||
if (typeof addToCalendarEmailValue === "string") {
|
||||
setAddToCalendarEmail(addToCalendarEmailValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function OAuthCallback() {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
if (auth.userInfo) {
|
||||
router.replace("/(tabs)");
|
||||
router.replace("/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function OAuthCallback() {
|
||||
window.localStorage.setItem(`oauth_callback_error_${state}`, errorMessage);
|
||||
window.localStorage.setItem(`oauth_callback_error_code_${state}`, error);
|
||||
}
|
||||
router.replace("/(tabs)");
|
||||
router.replace("/");
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -71,7 +71,7 @@ export default function OAuthCallback() {
|
||||
window.close();
|
||||
} else {
|
||||
// Redirect to main app
|
||||
router.replace("/(tabs)");
|
||||
router.replace("/");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -89,7 +89,7 @@ export default function OAuthCallback() {
|
||||
);
|
||||
window.close();
|
||||
} else {
|
||||
router.replace("/(tabs)");
|
||||
router.replace("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* BookingActionsModal Component - iOS Implementation
|
||||
*
|
||||
* iOS-specific modal for booking actions with Glass UI styling.
|
||||
* This provides a native iOS action sheet experience with Glass UI design.
|
||||
*/
|
||||
import type { Booking } from "../services/calcom";
|
||||
import type { BookingActionsResult } from "../utils/booking-actions";
|
||||
import { FullScreenModal } from "./FullScreenModal";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export interface BookingActionsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
booking: Booking | null;
|
||||
actions: BookingActionsResult;
|
||||
onReschedule: () => void;
|
||||
onRequestReschedule?: () => void;
|
||||
onEditLocation: () => void;
|
||||
onAddGuests: () => void;
|
||||
onViewRecordings: () => void;
|
||||
onMeetingSessionDetails: () => void;
|
||||
onMarkNoShow: () => void;
|
||||
onReportBooking: () => void;
|
||||
onCancelBooking: () => void;
|
||||
}
|
||||
|
||||
// Icon mapping for actions
|
||||
const ACTION_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
reschedule: "calendar",
|
||||
rescheduleRequest: "send",
|
||||
changeLocation: "location",
|
||||
addGuests: "person-add",
|
||||
viewRecordings: "videocam",
|
||||
meetingSessionDetails: "information-circle",
|
||||
markNoShow: "eye-off",
|
||||
report: "flag",
|
||||
cancel: "close-circle",
|
||||
};
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
enabled: boolean;
|
||||
isDanger?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
visible,
|
||||
enabled,
|
||||
isDanger = false,
|
||||
isLast = false,
|
||||
}: ActionButtonProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
const iconColor = !enabled ? "#C7C7CC" : isDanger ? "#FF3B30" : "#007AFF";
|
||||
const textColor = !enabled ? "#C7C7CC" : isDanger ? "#FF3B30" : "#000";
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!enabled) return;
|
||||
onPress();
|
||||
}}
|
||||
disabled={!enabled}
|
||||
className={`flex-row items-center px-4 py-3.5 active:bg-[#F8F9FA] ${
|
||||
!isLast ? "border-b border-gray-100" : ""
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="mr-3 h-7 w-7 items-center justify-center">
|
||||
<Ionicons name={icon} size={22} color={iconColor} />
|
||||
</View>
|
||||
<Text
|
||||
className={`flex-1 text-[17px] ${!enabled ? "text-[#C7C7CC]" : ""}`}
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{!enabled && (
|
||||
<View className="rounded bg-gray-100 px-2 py-0.5">
|
||||
<Text className="text-xs text-gray-500">Unavailable</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
function SectionHeader({ title }: SectionHeaderProps) {
|
||||
return (
|
||||
<View className="bg-[#F2F2F7] px-4 py-2">
|
||||
<Text className="text-[13px] font-medium uppercase tracking-wide text-gray-500">{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingActionsModal({
|
||||
visible,
|
||||
onClose,
|
||||
booking,
|
||||
actions,
|
||||
onReschedule,
|
||||
onRequestReschedule,
|
||||
onEditLocation,
|
||||
onAddGuests,
|
||||
onViewRecordings,
|
||||
onMeetingSessionDetails,
|
||||
onMarkNoShow,
|
||||
onReportBooking,
|
||||
onCancelBooking,
|
||||
}: BookingActionsModalProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!booking) return null;
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
onClose();
|
||||
action();
|
||||
};
|
||||
|
||||
// Check if any edit event actions are visible
|
||||
const hasEditEventActions =
|
||||
actions.reschedule.visible ||
|
||||
actions.rescheduleRequest.visible ||
|
||||
actions.changeLocation.visible ||
|
||||
actions.addGuests.visible;
|
||||
|
||||
// Check if any after event actions are visible
|
||||
const hasAfterEventActions =
|
||||
actions.viewRecordings.visible ||
|
||||
actions.meetingSessionDetails.visible ||
|
||||
actions.markNoShow.visible;
|
||||
|
||||
// Define edit event actions
|
||||
const editEventActions = [
|
||||
{
|
||||
key: "reschedule",
|
||||
icon: ACTION_ICONS.reschedule,
|
||||
label: "Reschedule Booking",
|
||||
onPress: () => handleAction(onReschedule),
|
||||
visible: actions.reschedule.visible,
|
||||
enabled: actions.reschedule.enabled,
|
||||
},
|
||||
...(onRequestReschedule
|
||||
? [
|
||||
{
|
||||
key: "rescheduleRequest",
|
||||
icon: ACTION_ICONS.rescheduleRequest,
|
||||
label: "Request Reschedule",
|
||||
onPress: () => handleAction(onRequestReschedule),
|
||||
visible: actions.rescheduleRequest.visible,
|
||||
enabled: actions.rescheduleRequest.enabled,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "changeLocation",
|
||||
icon: ACTION_ICONS.changeLocation,
|
||||
label: "Edit Location",
|
||||
onPress: () => handleAction(onEditLocation),
|
||||
visible: actions.changeLocation.visible,
|
||||
enabled: actions.changeLocation.enabled,
|
||||
},
|
||||
{
|
||||
key: "addGuests",
|
||||
icon: ACTION_ICONS.addGuests,
|
||||
label: "Add Guests",
|
||||
onPress: () => handleAction(onAddGuests),
|
||||
visible: actions.addGuests.visible,
|
||||
enabled: actions.addGuests.enabled,
|
||||
},
|
||||
].filter((action) => action.visible);
|
||||
|
||||
// Define after event actions
|
||||
const afterEventActions = [
|
||||
{
|
||||
key: "viewRecordings",
|
||||
icon: ACTION_ICONS.viewRecordings,
|
||||
label: "View Recordings",
|
||||
onPress: () => handleAction(onViewRecordings),
|
||||
visible: actions.viewRecordings.visible,
|
||||
enabled: actions.viewRecordings.enabled,
|
||||
},
|
||||
{
|
||||
key: "meetingSessionDetails",
|
||||
icon: ACTION_ICONS.meetingSessionDetails,
|
||||
label: "Meeting Session Details",
|
||||
onPress: () => handleAction(onMeetingSessionDetails),
|
||||
visible: actions.meetingSessionDetails.visible,
|
||||
enabled: actions.meetingSessionDetails.enabled,
|
||||
},
|
||||
{
|
||||
key: "markNoShow",
|
||||
icon: ACTION_ICONS.markNoShow,
|
||||
label: "Mark as No-Show",
|
||||
onPress: () => handleAction(onMarkNoShow),
|
||||
visible: actions.markNoShow.visible,
|
||||
enabled: actions.markNoShow.enabled,
|
||||
},
|
||||
].filter((action) => action.visible);
|
||||
|
||||
// Define danger zone actions
|
||||
const dangerZoneActions = [
|
||||
{
|
||||
key: "report",
|
||||
icon: ACTION_ICONS.report,
|
||||
label: "Report Booking",
|
||||
onPress: () => handleAction(onReportBooking),
|
||||
visible: true,
|
||||
enabled: true,
|
||||
isDanger: true,
|
||||
},
|
||||
{
|
||||
key: "cancel",
|
||||
icon: ACTION_ICONS.cancel,
|
||||
label: "Cancel Event",
|
||||
onPress: () => handleAction(onCancelBooking),
|
||||
visible: actions.cancel.visible,
|
||||
enabled: actions.cancel.enabled,
|
||||
isDanger: true,
|
||||
},
|
||||
].filter((action) => action.visible);
|
||||
|
||||
return (
|
||||
<FullScreenModal visible={visible} animationType="fade" onRequestClose={onClose}>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-end bg-black/40"
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="w-full"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={{ paddingBottom: insets.bottom || 16 }}
|
||||
>
|
||||
{/* Actions Card */}
|
||||
<View className="mx-4 mb-3 overflow-hidden rounded-2xl bg-white">
|
||||
{/* Booking Title Header */}
|
||||
<View className="border-b border-gray-100 px-4 py-4">
|
||||
<Text className="text-center text-[13px] font-medium text-gray-500" numberOfLines={1}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="max-h-[400px]">
|
||||
{/* Edit Event Section */}
|
||||
{hasEditEventActions && (
|
||||
<>
|
||||
<SectionHeader title="Edit Event" />
|
||||
{editEventActions.map((action, index) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
onPress={action.onPress}
|
||||
visible={action.visible}
|
||||
enabled={action.enabled}
|
||||
isLast={index === editEventActions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* After Event Section */}
|
||||
{hasAfterEventActions && (
|
||||
<>
|
||||
<SectionHeader title="After Event" />
|
||||
{afterEventActions.map((action, index) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
onPress={action.onPress}
|
||||
visible={action.visible}
|
||||
enabled={action.enabled}
|
||||
isLast={index === afterEventActions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Danger Zone Section */}
|
||||
{dangerZoneActions.length > 0 && (
|
||||
<>
|
||||
<SectionHeader title="Danger Zone" />
|
||||
{dangerZoneActions.map((action, index) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
onPress={action.onPress}
|
||||
visible={action.visible}
|
||||
enabled={action.enabled}
|
||||
isDanger={action.isDanger}
|
||||
isLast={index === dangerZoneActions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<TouchableOpacity
|
||||
className="mx-4 overflow-hidden rounded-2xl bg-white"
|
||||
onPress={onClose}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-center text-[17px] font-semibold text-[#007AFF]">Cancel</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* BookingActionsModal Component
|
||||
* BookingActionsModal Component - Android/Web Implementation
|
||||
*
|
||||
* A reusable modal component for booking actions that can be used in both
|
||||
* the bookings list screen and the booking detail screen.
|
||||
*
|
||||
* This component uses the centralized action gating utility for consistent
|
||||
* action visibility and enabled state across the app.
|
||||
*
|
||||
* Note: iOS uses BookingActionsModal.ios.tsx with native Glass UI styling.
|
||||
*/
|
||||
|
||||
import type { Booking } from "../services/calcom";
|
||||
import type { BookingActionsResult } from "../utils/booking-actions";
|
||||
import { FullScreenModal } from "./FullScreenModal";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, Alert } from "react-native";
|
||||
|
||||
import { FullScreenModal } from "./FullScreenModal";
|
||||
import type { Booking } from "../services/calcom";
|
||||
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export interface BookingActionsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
booking: Booking | null;
|
||||
hasLocationUrl?: boolean;
|
||||
isUpcoming?: boolean; // When true, disables "after event" actions (View Recordings, Session Details, Mark No-Show)
|
||||
isPast?: boolean; // When true, disables "edit event" actions (Reschedule, Edit Location, Add Guests) and Cancel Booking
|
||||
isCancelled?: boolean; // When true, only Report Booking and Mark as No-Show are enabled
|
||||
isUnconfirmed?: boolean; // When true, disables Reschedule, Edit Location, Add Guests
|
||||
actions: BookingActionsResult;
|
||||
onReschedule: () => void;
|
||||
onRequestReschedule?: () => void;
|
||||
onEditLocation: () => void;
|
||||
onAddGuests: () => void;
|
||||
onViewRecordings: () => void;
|
||||
@@ -31,26 +33,91 @@ export interface BookingActionsModalProps {
|
||||
onCancelBooking: () => void;
|
||||
}
|
||||
|
||||
// Style constants for easy customization
|
||||
const ICON_SIZE = 16;
|
||||
const ICON_COLOR = "#6B7280";
|
||||
const ICON_COLOR_DANGER = "#800000"; // Maroon
|
||||
const DISABLED_ICON_COLOR = "#D1D5DB";
|
||||
const TEXT_CLASS = "text-lg";
|
||||
const TEXT_COLOR_CLASS = "text-gray-900";
|
||||
const TEXT_COLOR_DANGER_CLASS = "text-[#800000]"; // Maroon
|
||||
const DISABLED_TEXT_COLOR_CLASS = "text-gray-300";
|
||||
// Icon mapping for actions
|
||||
const ACTION_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
reschedule: "calendar-outline",
|
||||
rescheduleRequest: "send-outline",
|
||||
changeLocation: "location-outline",
|
||||
addGuests: "person-add-outline",
|
||||
viewRecordings: "videocam-outline",
|
||||
meetingSessionDetails: "information-circle-outline",
|
||||
markNoShow: "eye-off-outline",
|
||||
report: "flag-outline",
|
||||
cancel: "close-circle-outline",
|
||||
};
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
enabled: boolean;
|
||||
isDanger?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
visible,
|
||||
enabled,
|
||||
isDanger = false,
|
||||
isLast = false,
|
||||
}: ActionButtonProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
const iconColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#6B7280";
|
||||
const textColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#111827";
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!enabled) return;
|
||||
onPress();
|
||||
}}
|
||||
disabled={!enabled}
|
||||
className={`flex-row items-center px-4 py-3 active:bg-gray-50 ${
|
||||
!isLast ? "border-b border-gray-100" : ""
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="mr-3 h-6 w-6 items-center justify-center">
|
||||
<Ionicons name={icon} size={20} color={iconColor} />
|
||||
</View>
|
||||
<Text className="flex-1 text-[16px]" style={{ color: textColor }}>
|
||||
{label}
|
||||
</Text>
|
||||
{!enabled && (
|
||||
<View className="rounded bg-gray-100 px-2 py-0.5">
|
||||
<Text className="text-xs text-gray-500">Unavailable</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
function SectionHeader({ title }: SectionHeaderProps) {
|
||||
return (
|
||||
<View className="bg-gray-50 px-4 py-2">
|
||||
<Text className="text-[12px] font-semibold uppercase tracking-wide text-gray-500">
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingActionsModal({
|
||||
visible,
|
||||
onClose,
|
||||
booking,
|
||||
hasLocationUrl = false,
|
||||
isUpcoming = false,
|
||||
isPast = false,
|
||||
isCancelled = false,
|
||||
isUnconfirmed = false,
|
||||
actions,
|
||||
onReschedule,
|
||||
onRequestReschedule,
|
||||
onEditLocation,
|
||||
onAddGuests,
|
||||
onViewRecordings,
|
||||
@@ -59,228 +126,207 @@ export function BookingActionsModal({
|
||||
onReportBooking,
|
||||
onCancelBooking,
|
||||
}: BookingActionsModalProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!booking) return null;
|
||||
|
||||
// For cancelled bookings, only Report Booking and Mark as No-Show are enabled
|
||||
// "After event" actions (except Mark as No-Show) are disabled for upcoming, cancelled, or unconfirmed bookings
|
||||
const afterEventActionsDisabled = isUpcoming || isCancelled || isUnconfirmed;
|
||||
// "Edit event" actions (Reschedule, Edit Location, Add Guests) are disabled for past, cancelled, or unconfirmed bookings
|
||||
const editEventActionsDisabled = isPast || isCancelled || isUnconfirmed;
|
||||
// Cancel booking is disabled for past or cancelled bookings (but NOT for unconfirmed - user can still cancel/decline)
|
||||
const cancelBookingDisabled = isPast || isCancelled;
|
||||
const handleAction = (action: () => void) => {
|
||||
onClose();
|
||||
action();
|
||||
};
|
||||
|
||||
// Check if any edit event actions are visible
|
||||
const hasEditEventActions =
|
||||
actions.reschedule.visible ||
|
||||
actions.rescheduleRequest.visible ||
|
||||
actions.changeLocation.visible ||
|
||||
actions.addGuests.visible;
|
||||
|
||||
// Check if any after event actions are visible
|
||||
const hasAfterEventActions =
|
||||
actions.viewRecordings.visible ||
|
||||
actions.meetingSessionDetails.visible ||
|
||||
actions.markNoShow.visible;
|
||||
|
||||
// Define edit event actions
|
||||
const editEventActions = [
|
||||
{
|
||||
key: "reschedule",
|
||||
icon: ACTION_ICONS.reschedule,
|
||||
label: "Reschedule Booking",
|
||||
onPress: () => handleAction(onReschedule),
|
||||
visible: actions.reschedule.visible,
|
||||
enabled: actions.reschedule.enabled,
|
||||
},
|
||||
...(onRequestReschedule
|
||||
? [
|
||||
{
|
||||
key: "rescheduleRequest",
|
||||
icon: ACTION_ICONS.rescheduleRequest,
|
||||
label: "Request Reschedule",
|
||||
onPress: () => handleAction(onRequestReschedule),
|
||||
visible: actions.rescheduleRequest.visible,
|
||||
enabled: actions.rescheduleRequest.enabled,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "changeLocation",
|
||||
icon: ACTION_ICONS.changeLocation,
|
||||
label: "Edit Location",
|
||||
onPress: () => handleAction(onEditLocation),
|
||||
visible: actions.changeLocation.visible,
|
||||
enabled: actions.changeLocation.enabled,
|
||||
},
|
||||
{
|
||||
key: "addGuests",
|
||||
icon: ACTION_ICONS.addGuests,
|
||||
label: "Add Guests",
|
||||
onPress: () => handleAction(onAddGuests),
|
||||
visible: actions.addGuests.visible,
|
||||
enabled: actions.addGuests.enabled,
|
||||
},
|
||||
].filter((action) => action.visible);
|
||||
|
||||
// Define after event actions
|
||||
const afterEventActions = [
|
||||
{
|
||||
key: "viewRecordings",
|
||||
icon: ACTION_ICONS.viewRecordings,
|
||||
label: "View Recordings",
|
||||
onPress: () => handleAction(onViewRecordings),
|
||||
visible: actions.viewRecordings.visible,
|
||||
enabled: actions.viewRecordings.enabled,
|
||||
},
|
||||
{
|
||||
key: "meetingSessionDetails",
|
||||
icon: ACTION_ICONS.meetingSessionDetails,
|
||||
label: "Meeting Session Details",
|
||||
onPress: () => handleAction(onMeetingSessionDetails),
|
||||
visible: actions.meetingSessionDetails.visible,
|
||||
enabled: actions.meetingSessionDetails.enabled,
|
||||
},
|
||||
{
|
||||
key: "markNoShow",
|
||||
icon: ACTION_ICONS.markNoShow,
|
||||
label: "Mark as No-Show",
|
||||
onPress: () => handleAction(onMarkNoShow),
|
||||
visible: actions.markNoShow.visible,
|
||||
enabled: actions.markNoShow.enabled,
|
||||
},
|
||||
].filter((action) => action.visible);
|
||||
|
||||
// Define danger zone actions
|
||||
const dangerZoneActions = [
|
||||
{
|
||||
key: "report",
|
||||
icon: ACTION_ICONS.report,
|
||||
label: "Report Booking",
|
||||
onPress: () => handleAction(onReportBooking),
|
||||
visible: true,
|
||||
enabled: true,
|
||||
isDanger: true,
|
||||
},
|
||||
{
|
||||
key: "cancel",
|
||||
icon: ACTION_ICONS.cancel,
|
||||
label: "Cancel Event",
|
||||
onPress: () => handleAction(onCancelBooking),
|
||||
visible: actions.cancel.visible,
|
||||
enabled: actions.cancel.enabled,
|
||||
isDanger: true,
|
||||
},
|
||||
].filter((action) => action.visible);
|
||||
|
||||
return (
|
||||
<FullScreenModal visible={visible} animationType="fade" onRequestClose={onClose}>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
className="flex-1 items-center justify-center bg-black/50 p-4"
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
|
||||
className="w-full max-w-md"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Actions List */}
|
||||
<View className="p-2">
|
||||
{/* Edit event label */}
|
||||
<View className="px-4 py-1">
|
||||
<Text className="text-xs font-medium text-gray-500">Edit event</Text>
|
||||
{/* Actions Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-2xl bg-white shadow-lg">
|
||||
{/* Booking Title Header */}
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="text-center text-[14px] font-medium text-gray-600" numberOfLines={1}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Reschedule Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (editEventActionsDisabled) return;
|
||||
onClose();
|
||||
onReschedule();
|
||||
}}
|
||||
disabled={editEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={ICON_SIZE}
|
||||
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Reschedule Booking
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<ScrollView className="max-h-[400px]">
|
||||
{/* Edit Event Section */}
|
||||
{hasEditEventActions && (
|
||||
<>
|
||||
<SectionHeader title="Edit Event" />
|
||||
{editEventActions.map((action, index) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
onPress={action.onPress}
|
||||
visible={action.visible}
|
||||
enabled={action.enabled}
|
||||
isLast={index === editEventActions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edit Location */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (editEventActionsDisabled) return;
|
||||
onClose();
|
||||
onEditLocation();
|
||||
}}
|
||||
disabled={editEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="location-outline"
|
||||
size={ICON_SIZE}
|
||||
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Edit Location
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* After Event Section */}
|
||||
{hasAfterEventActions && (
|
||||
<>
|
||||
<SectionHeader title="After Event" />
|
||||
{afterEventActions.map((action, index) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
onPress={action.onPress}
|
||||
visible={action.visible}
|
||||
enabled={action.enabled}
|
||||
isLast={index === afterEventActions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add Guests */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (editEventActionsDisabled) return;
|
||||
onClose();
|
||||
onAddGuests();
|
||||
}}
|
||||
disabled={editEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="person-add-outline"
|
||||
size={ICON_SIZE}
|
||||
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Add Guests
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Danger Zone Section */}
|
||||
{dangerZoneActions.length > 0 && (
|
||||
<>
|
||||
<SectionHeader title="Danger Zone" />
|
||||
{dangerZoneActions.map((action, index) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
onPress={action.onPress}
|
||||
visible={action.visible}
|
||||
enabled={action.enabled}
|
||||
isDanger={action.isDanger}
|
||||
isLast={index === dangerZoneActions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* After event label */}
|
||||
<View className="px-4 py-1">
|
||||
<Text className="text-xs font-medium text-gray-500">After event</Text>
|
||||
{/* Cancel Button */}
|
||||
<TouchableOpacity
|
||||
className="overflow-hidden rounded-2xl bg-white shadow-lg"
|
||||
onPress={onClose}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="px-4 py-3">
|
||||
<Text className="text-center text-[16px] font-semibold text-gray-700">Cancel</Text>
|
||||
</View>
|
||||
|
||||
{/* View Recordings */}
|
||||
{hasLocationUrl ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (afterEventActionsDisabled) return;
|
||||
onClose();
|
||||
onViewRecordings();
|
||||
}}
|
||||
disabled={afterEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="videocam-outline"
|
||||
size={ICON_SIZE}
|
||||
color={afterEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${afterEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
View Recordings
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{/* Meeting Session Details */}
|
||||
{hasLocationUrl ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (afterEventActionsDisabled) return;
|
||||
onClose();
|
||||
onMeetingSessionDetails();
|
||||
}}
|
||||
disabled={afterEventActionsDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="information-circle-outline"
|
||||
size={ICON_SIZE}
|
||||
color={afterEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${afterEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Meeting Session Details
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (isUpcoming || isUnconfirmed) return;
|
||||
onClose();
|
||||
onMarkNoShow();
|
||||
}}
|
||||
disabled={isUpcoming || isUnconfirmed}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="eye-off-outline"
|
||||
size={ICON_SIZE}
|
||||
color={isUpcoming || isUnconfirmed ? DISABLED_ICON_COLOR : ICON_COLOR}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${isUpcoming || isUnconfirmed ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
|
||||
>
|
||||
Mark as No-Show
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* Report Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onReportBooking();
|
||||
}}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons name="flag-outline" size={ICON_SIZE} color={ICON_COLOR_DANGER} />
|
||||
<Text className={`ml-3 ${TEXT_CLASS} ${TEXT_COLOR_DANGER_CLASS}`}>
|
||||
Report Booking
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* Cancel Booking */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (cancelBookingDisabled) return;
|
||||
onClose();
|
||||
onCancelBooking();
|
||||
}}
|
||||
disabled={cancelBookingDisabled}
|
||||
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
|
||||
>
|
||||
<Ionicons
|
||||
name="close-circle-outline"
|
||||
size={ICON_SIZE}
|
||||
color={cancelBookingDisabled ? DISABLED_ICON_COLOR : ICON_COLOR_DANGER}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-3 ${TEXT_CLASS} ${cancelBookingDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_DANGER_CLASS}`}
|
||||
>
|
||||
Cancel Event
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Cancel button */}
|
||||
<View className="border-t border-gray-200 p-2 md:p-4">
|
||||
<TouchableOpacity className="w-full rounded-lg bg-gray-100 p-3" onPress={onClose}>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AppPressable } from "./AppPressable";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { View, Text, ActivityIndicator } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
interface GlassModalHeaderProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onAction?: () => void;
|
||||
actionLabel?: string;
|
||||
actionDisabled?: boolean;
|
||||
actionLoading?: boolean;
|
||||
/** Use icon button style (like three-dot menu) instead of text */
|
||||
actionIcon?: keyof typeof Ionicons.glyphMap;
|
||||
}
|
||||
|
||||
export function GlassModalHeader({
|
||||
title,
|
||||
onClose,
|
||||
onAction,
|
||||
actionLabel = "Done",
|
||||
actionDisabled = false,
|
||||
actionLoading = false,
|
||||
actionIcon,
|
||||
}: GlassModalHeaderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const isDisabled = actionDisabled || actionLoading;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingTop: insets.top,
|
||||
backgroundColor: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
<View className="min-h-[44px] flex-row items-center justify-between px-4">
|
||||
{/* Back Button - matching native iOS style */}
|
||||
<AppPressable
|
||||
className="h-11 w-11 items-center justify-center"
|
||||
onPress={onClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={28} color="#000" />
|
||||
</AppPressable>
|
||||
|
||||
{/* Title - centered */}
|
||||
<Text className="flex-1 text-center text-[17px] font-semibold text-black" numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Action Button - matching senior's Stack.Header.Menu style */}
|
||||
{onAction ? (
|
||||
<AppPressable
|
||||
className={`h-11 min-w-[44px] items-center justify-center rounded-full ${
|
||||
actionIcon ? "bg-[#F2F2F7]" : ""
|
||||
} ${isDisabled ? "opacity-40" : ""}`}
|
||||
onPress={onAction}
|
||||
disabled={isDisabled}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
style={actionIcon ? { width: 44, height: 44 } : undefined}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<ActivityIndicator size="small" color="#000" />
|
||||
) : actionIcon ? (
|
||||
// Icon button style (like the three-dot menu)
|
||||
<Ionicons name={actionIcon} size={22} color="#000" />
|
||||
) : (
|
||||
// Text button style (like "Save", "Done")
|
||||
<View className="items-center rounded-full bg-black px-4 py-2">
|
||||
<Text className="text-[15px] font-semibold text-white">{actionLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
</AppPressable>
|
||||
) : (
|
||||
<View style={{ width: 44 }} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { SvgImage } from "../SvgImage";
|
||||
import { getMeetingInfo } from "../../utils/meetings-utils";
|
||||
import { formatTime, formatDate, getHostAndAttendeesDisplay } from "../../utils/bookings-utils";
|
||||
import { showErrorAlert } from "../../utils/alerts";
|
||||
import { getBookingActions } from "../../utils/booking-actions";
|
||||
|
||||
export const BookingListItem: React.FC<BookingListItemProps> = ({
|
||||
booking,
|
||||
@@ -41,6 +42,22 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
|
||||
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
|
||||
const meetingInfo = getMeetingInfo(booking.location);
|
||||
|
||||
// Check if any attendee is marked as no-show
|
||||
const hasNoShowAttendee = booking.attendees?.some(
|
||||
(att: any) => att.noShow === true || att.absent === true
|
||||
);
|
||||
|
||||
// Use centralized action gating for consistency
|
||||
const actions = React.useMemo(() => {
|
||||
return getBookingActions({
|
||||
booking,
|
||||
eventType: undefined,
|
||||
currentUserId: undefined, // Not available in this context
|
||||
currentUserEmail: userEmail,
|
||||
isOnline: true, // Assume online
|
||||
});
|
||||
}, [booking, userEmail]);
|
||||
|
||||
// Define context menu actions based on booking state
|
||||
type ContextMenuAction = {
|
||||
label: string;
|
||||
@@ -78,7 +95,8 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
|
||||
icon: "video",
|
||||
onPress: () => onViewRecordings?.(booking),
|
||||
role: "default",
|
||||
visible: hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onViewRecordings,
|
||||
visible:
|
||||
actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
|
||||
},
|
||||
{
|
||||
label: "Meeting Session Details",
|
||||
@@ -86,14 +104,16 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
|
||||
onPress: () => onMeetingSessionDetails?.(booking),
|
||||
role: "default",
|
||||
visible:
|
||||
hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onMeetingSessionDetails,
|
||||
actions.meetingSessionDetails.visible &&
|
||||
actions.meetingSessionDetails.enabled &&
|
||||
!!onMeetingSessionDetails,
|
||||
},
|
||||
{
|
||||
label: "Mark as No-Show",
|
||||
icon: "eye.slash",
|
||||
onPress: () => onMarkNoShow?.(booking),
|
||||
role: "default",
|
||||
visible: !isUpcoming && !isPending && !!onMarkNoShow,
|
||||
visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
|
||||
},
|
||||
// Other Actions
|
||||
{
|
||||
@@ -156,7 +176,15 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
|
||||
) : null}
|
||||
{/* Host and Attendees */}
|
||||
{hostAndAttendeesDisplay ? (
|
||||
<Text className="mb-2 text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
|
||||
<View className="mb-2 flex-row items-center">
|
||||
<Text className="text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
|
||||
{hasNoShowAttendee && (
|
||||
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-1.5 py-0.5">
|
||||
<Ionicons name="eye-off" size={10} color="#DC2626" />
|
||||
<Text className="ml-0.5 text-[10px] font-medium text-[#DC2626]">No-show</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{/* Meeting Link */}
|
||||
{meetingInfo ? (
|
||||
|
||||
@@ -29,6 +29,11 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
|
||||
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
|
||||
const meetingInfo = getMeetingInfo(booking.location);
|
||||
|
||||
// Check if any attendee is marked as no-show
|
||||
const hasNoShowAttendee = booking.attendees?.some(
|
||||
(att: any) => att.noShow === true || att.absent === true
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="border-b border-[#E5E5EA] bg-white">
|
||||
<TouchableOpacity
|
||||
@@ -69,7 +74,15 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
|
||||
) : null}
|
||||
{/* Host and Attendees */}
|
||||
{hostAndAttendeesDisplay ? (
|
||||
<Text className="mb-2 text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
|
||||
<View className="mb-2 flex-row items-center">
|
||||
<Text className="text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
|
||||
{hasNoShowAttendee && (
|
||||
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-1.5 py-0.5">
|
||||
<Ionicons name="eye-off" size={10} color="#DC2626" />
|
||||
<Text className="ml-0.5 text-[10px] font-medium text-[#DC2626]">No-show</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{/* Meeting Link */}
|
||||
{meetingInfo ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useDeclineBooking,
|
||||
useRescheduleBooking,
|
||||
useBookingActions,
|
||||
useBookingActionModals,
|
||||
type BookingFilter,
|
||||
} from "../../hooks";
|
||||
import { offlineAwareRefresh } from "../../utils/network";
|
||||
@@ -102,23 +103,12 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
|
||||
// Booking actions hook
|
||||
const {
|
||||
showRescheduleModal,
|
||||
rescheduleBooking,
|
||||
rescheduleDate,
|
||||
setRescheduleDate,
|
||||
rescheduleTime,
|
||||
setRescheduleTime,
|
||||
rescheduleReason,
|
||||
setRescheduleReason,
|
||||
showRejectModal,
|
||||
rejectReason,
|
||||
setRejectReason,
|
||||
selectedBooking,
|
||||
setSelectedBooking,
|
||||
handleBookingPress,
|
||||
handleRescheduleBooking,
|
||||
handleSubmitReschedule,
|
||||
handleCloseRescheduleModal,
|
||||
handleCancelBooking,
|
||||
handleInlineConfirm,
|
||||
handleOpenRejectModal,
|
||||
@@ -135,6 +125,75 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
isRescheduling,
|
||||
});
|
||||
|
||||
// Booking action modals hook
|
||||
const { selectedBooking: actionModalBooking } = useBookingActionModals();
|
||||
|
||||
// Navigate to reschedule screen (same pattern as booking detail)
|
||||
const handleNavigateToReschedule = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/reschedule",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Navigate to edit location screen (same pattern as booking detail)
|
||||
const handleNavigateToEditLocation = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/edit-location",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Navigate to add guests screen (same pattern as booking detail)
|
||||
const handleNavigateToAddGuests = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/add-guests",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Navigate to mark no show screen (same pattern as booking detail)
|
||||
const handleNavigateToMarkNoShow = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/mark-no-show",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Navigate to view recordings screen (same pattern as booking detail)
|
||||
const handleNavigateToViewRecordings = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/view-recordings",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Navigate to meeting session details screen (same pattern as booking detail)
|
||||
const handleNavigateToMeetingSessionDetails = React.useCallback(
|
||||
(booking: Booking) => {
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/meeting-session-details",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Sort bookings based on active filter
|
||||
const bookings = useMemo(() => {
|
||||
if (!rawBookings || !Array.isArray(rawBookings)) return [];
|
||||
@@ -208,28 +267,15 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
setSelectedBooking(booking);
|
||||
setShowBookingActionsModal(true);
|
||||
}}
|
||||
// iOS context menu action handlers
|
||||
onReschedule={handleRescheduleBooking}
|
||||
onEditLocation={() => {
|
||||
Alert.alert("Edit Location", "Edit location functionality coming soon");
|
||||
}}
|
||||
onAddGuests={() => {
|
||||
Alert.alert("Add Guests", "Add guests functionality coming soon");
|
||||
}}
|
||||
onViewRecordings={() => {
|
||||
Alert.alert("View Recordings", "View recordings functionality coming soon");
|
||||
}}
|
||||
onMeetingSessionDetails={() => {
|
||||
Alert.alert(
|
||||
"Meeting Session Details",
|
||||
"Meeting session details functionality coming soon"
|
||||
);
|
||||
}}
|
||||
onMarkNoShow={() => {
|
||||
Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon");
|
||||
}}
|
||||
// iOS context menu action handlers - now use screen navigation
|
||||
onReschedule={handleNavigateToReschedule}
|
||||
onEditLocation={handleNavigateToEditLocation}
|
||||
onAddGuests={handleNavigateToAddGuests}
|
||||
onViewRecordings={handleNavigateToViewRecordings}
|
||||
onMeetingSessionDetails={handleNavigateToMeetingSessionDetails}
|
||||
onMarkNoShow={handleNavigateToMarkNoShow}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality coming soon");
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
}}
|
||||
onCancelBooking={handleCancelBooking}
|
||||
/>
|
||||
@@ -368,21 +414,16 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
|
||||
{/* Modals */}
|
||||
<BookingModals
|
||||
showRescheduleModal={showRescheduleModal}
|
||||
rescheduleBooking={rescheduleBooking}
|
||||
rescheduleDate={rescheduleDate}
|
||||
rescheduleTime={rescheduleTime}
|
||||
rescheduleReason={rescheduleReason}
|
||||
showRescheduleModal={false}
|
||||
rescheduleBooking={null}
|
||||
isRescheduling={isRescheduling}
|
||||
onRescheduleClose={handleCloseRescheduleModal}
|
||||
onRescheduleSubmit={handleSubmitReschedule}
|
||||
onRescheduleDateChange={setRescheduleDate}
|
||||
onRescheduleTimeChange={setRescheduleTime}
|
||||
onRescheduleReasonChange={setRescheduleReason}
|
||||
onRescheduleClose={() => {}}
|
||||
onRescheduleSubmit={async () => {}}
|
||||
showRejectModal={showRejectModal}
|
||||
rejectReason={rejectReason}
|
||||
isDeclining={isDeclining}
|
||||
onRejectClose={handleCloseRejectModal}
|
||||
currentUserEmail={userInfo?.email}
|
||||
onRejectSubmit={handleSubmitReject}
|
||||
onRejectReasonChange={setRejectReason}
|
||||
showFilterModal={showFilterModal}
|
||||
@@ -395,12 +436,43 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
selectedBooking={selectedBooking}
|
||||
onActionsClose={() => setShowBookingActionsModal(false)}
|
||||
onReschedule={() => {
|
||||
if (selectedBooking) handleRescheduleBooking(selectedBooking);
|
||||
// Navigate to reschedule screen instead of opening modal
|
||||
if (selectedBooking) {
|
||||
setShowBookingActionsModal(false);
|
||||
handleNavigateToReschedule(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (selectedBooking) handleCancelBooking(selectedBooking);
|
||||
}}
|
||||
onEditLocation={(booking) => {
|
||||
// Navigate to edit location screen instead of opening modal
|
||||
setShowBookingActionsModal(false);
|
||||
handleNavigateToEditLocation(booking);
|
||||
}}
|
||||
onAddGuests={(booking) => {
|
||||
// Navigate to add guests screen instead of opening modal
|
||||
setShowBookingActionsModal(false);
|
||||
handleNavigateToAddGuests(booking);
|
||||
}}
|
||||
onViewRecordings={(booking) => {
|
||||
// Navigate to view recordings screen instead of opening modal
|
||||
setShowBookingActionsModal(false);
|
||||
handleNavigateToViewRecordings(booking);
|
||||
}}
|
||||
onMeetingSessionDetails={(booking) => {
|
||||
// Navigate to meeting session details screen instead of opening modal
|
||||
setShowBookingActionsModal(false);
|
||||
handleNavigateToMeetingSessionDetails(booking);
|
||||
}}
|
||||
onMarkNoShow={(booking) => {
|
||||
// Navigate to mark no show screen instead of opening modal
|
||||
setShowBookingActionsModal(false);
|
||||
handleNavigateToMarkNoShow(booking);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Action Modals */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
Alert,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
|
||||
import type { Booking, EventType } from "../../services/calcom";
|
||||
import { getBookingActions, type BookingActionsResult } from "../../utils/booking-actions";
|
||||
import { FullScreenModal } from "../FullScreenModal";
|
||||
import { BookingActionsModal } from "../BookingActionsModal";
|
||||
|
||||
// Empty actions result for when no booking is selected
|
||||
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 {
|
||||
// Reschedule modal props
|
||||
showRescheduleModal: boolean;
|
||||
rescheduleBooking: Booking | null;
|
||||
rescheduleDate: string;
|
||||
rescheduleTime: string;
|
||||
rescheduleReason: string;
|
||||
isRescheduling: boolean;
|
||||
onRescheduleClose: () => void;
|
||||
onRescheduleSubmit: () => void;
|
||||
onRescheduleDateChange: (date: string) => void;
|
||||
onRescheduleTimeChange: (time: string) => void;
|
||||
onRescheduleReasonChange: (reason: string) => void;
|
||||
onRescheduleSubmit: (date: string, time: string, reason?: string) => Promise<void>;
|
||||
|
||||
// Reject modal props
|
||||
showRejectModal: boolean;
|
||||
@@ -50,20 +57,24 @@ interface BookingModalsProps {
|
||||
onActionsClose: () => void;
|
||||
onReschedule: () => void;
|
||||
onCancel: () => void;
|
||||
|
||||
// User info for action gating (optional)
|
||||
currentUserEmail?: string;
|
||||
|
||||
// Action modal handlers (optional - if not provided, actions will be disabled)
|
||||
onEditLocation?: (booking: Booking) => void;
|
||||
onAddGuests?: (booking: Booking) => void;
|
||||
onViewRecordings?: (booking: Booking) => void;
|
||||
onMeetingSessionDetails?: (booking: Booking) => void;
|
||||
onMarkNoShow?: (booking: Booking) => void;
|
||||
}
|
||||
|
||||
export const BookingModals: React.FC<BookingModalsProps> = ({
|
||||
showRescheduleModal,
|
||||
rescheduleBooking,
|
||||
rescheduleDate,
|
||||
rescheduleTime,
|
||||
rescheduleReason,
|
||||
isRescheduling,
|
||||
onRescheduleClose,
|
||||
onRescheduleSubmit,
|
||||
onRescheduleDateChange,
|
||||
onRescheduleTimeChange,
|
||||
onRescheduleReasonChange,
|
||||
showRejectModal,
|
||||
rejectReason,
|
||||
isDeclining,
|
||||
@@ -81,7 +92,25 @@ export const BookingModals: React.FC<BookingModalsProps> = ({
|
||||
onActionsClose,
|
||||
onReschedule,
|
||||
onCancel,
|
||||
currentUserEmail,
|
||||
onEditLocation,
|
||||
onAddGuests,
|
||||
onViewRecordings,
|
||||
onMeetingSessionDetails,
|
||||
onMarkNoShow,
|
||||
}) => {
|
||||
// Compute actions using centralized gating
|
||||
const actions = useMemo(() => {
|
||||
if (!selectedBooking) return EMPTY_ACTIONS;
|
||||
return getBookingActions({
|
||||
booking: selectedBooking,
|
||||
eventType: undefined, // EventType not available in this context
|
||||
currentUserId: undefined,
|
||||
currentUserEmail: currentUserEmail,
|
||||
isOnline: true, // Assume online for now
|
||||
});
|
||||
}, [selectedBooking, currentUserEmail]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter Modal - Only rendered if props are provided (non-iOS) */}
|
||||
@@ -148,127 +177,39 @@ export const BookingModals: React.FC<BookingModalsProps> = ({
|
||||
visible={showBookingActionsModal}
|
||||
onClose={onActionsClose}
|
||||
booking={selectedBooking}
|
||||
hasLocationUrl={!!selectedBooking?.location}
|
||||
isUpcoming={
|
||||
selectedBooking
|
||||
? new Date(selectedBooking.endTime || selectedBooking.end || "") >= new Date() &&
|
||||
selectedBooking.status?.toUpperCase() !== "PENDING"
|
||||
: false
|
||||
}
|
||||
isPast={
|
||||
selectedBooking
|
||||
? new Date(selectedBooking.endTime || selectedBooking.end || "") < new Date()
|
||||
: false
|
||||
}
|
||||
isCancelled={selectedBooking?.status?.toUpperCase() === "CANCELLED"}
|
||||
isUnconfirmed={selectedBooking?.status?.toUpperCase() === "PENDING"}
|
||||
actions={actions}
|
||||
onReschedule={onReschedule}
|
||||
onEditLocation={() => {
|
||||
Alert.alert("Edit Location", "Edit location functionality coming soon");
|
||||
if (selectedBooking && onEditLocation) {
|
||||
onEditLocation(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onAddGuests={() => {
|
||||
Alert.alert("Add Guests", "Add guests functionality coming soon");
|
||||
if (selectedBooking && onAddGuests) {
|
||||
onAddGuests(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onViewRecordings={() => {
|
||||
Alert.alert("View Recordings", "View recordings functionality coming soon");
|
||||
if (selectedBooking && onViewRecordings) {
|
||||
onViewRecordings(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onMeetingSessionDetails={() => {
|
||||
Alert.alert(
|
||||
"Meeting Session Details",
|
||||
"Meeting session details functionality coming soon"
|
||||
);
|
||||
if (selectedBooking && onMeetingSessionDetails) {
|
||||
onMeetingSessionDetails(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onMarkNoShow={() => {
|
||||
Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon");
|
||||
if (selectedBooking && onMarkNoShow) {
|
||||
onMarkNoShow(selectedBooking);
|
||||
}
|
||||
}}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality coming soon");
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
}}
|
||||
onCancelBooking={onCancel}
|
||||
/>
|
||||
|
||||
{/* Reschedule Modal */}
|
||||
<FullScreenModal visible={showRescheduleModal} onRequestClose={onRescheduleClose}>
|
||||
<ScrollView className="flex-1 p-4">
|
||||
{rescheduleBooking ? (
|
||||
<>
|
||||
<Text className="mb-4 text-base text-gray-600">
|
||||
Reschedule "{rescheduleBooking.title}"
|
||||
</Text>
|
||||
|
||||
{/* Date Input */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-700">
|
||||
New Date (YYYY-MM-DD)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-3 text-base"
|
||||
value={rescheduleDate}
|
||||
onChangeText={onRescheduleDateChange}
|
||||
placeholder="2024-12-25"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
keyboardType="default"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Input */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-700">
|
||||
New Time (HH:MM, 24-hour format)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-3 text-base"
|
||||
value={rescheduleTime}
|
||||
onChangeText={onRescheduleTimeChange}
|
||||
placeholder="14:30"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
keyboardType="default"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Reason Input */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-700">Reason (optional)</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-3 text-base"
|
||||
value={rescheduleReason}
|
||||
onChangeText={onRescheduleReasonChange}
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Submit Button */}
|
||||
<TouchableOpacity
|
||||
className={`rounded-lg p-4 ${isRescheduling ? "bg-gray-400" : "bg-black"}`}
|
||||
onPress={onRescheduleSubmit}
|
||||
disabled={isRescheduling}
|
||||
>
|
||||
{isRescheduling ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">
|
||||
Reschedule Booking
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<TouchableOpacity
|
||||
className="mt-3 rounded-lg bg-gray-100 p-4"
|
||||
onPress={onRescheduleClose}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</FullScreenModal>
|
||||
|
||||
{/* Reject Booking Modal */}
|
||||
<FullScreenModal
|
||||
visible={showRejectModal}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* AddGuestsScreen Component
|
||||
*
|
||||
* Screen content for adding guests to a booking.
|
||||
* Used with the add-guests route that has native Stack.Header.
|
||||
*/
|
||||
import type { Booking } from "../../services/calcom";
|
||||
import { CalComAPIService } from "../../services/calcom";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export interface AddGuestsScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
}
|
||||
|
||||
// Handle type for parent component to call submit
|
||||
export interface AddGuestsScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email.trim());
|
||||
}
|
||||
|
||||
export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreenProps>(
|
||||
function AddGuestsScreen({ booking, onSuccess, onSavingChange }, ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [guests, setGuests] = useState<Array<{ email: string; name?: string }>>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Notify parent of saving state changes
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleAddGuest = useCallback(() => {
|
||||
const trimmedEmail = email.trim();
|
||||
if (!trimmedEmail) {
|
||||
Alert.alert("Error", "Please enter an email address");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEmail(trimmedEmail)) {
|
||||
Alert.alert("Error", "Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
if (guests.some((g) => g.email.toLowerCase() === trimmedEmail.toLowerCase())) {
|
||||
Alert.alert("Error", "This guest has already been added");
|
||||
return;
|
||||
}
|
||||
|
||||
setGuests([...guests, { email: trimmedEmail, name: name.trim() || undefined }]);
|
||||
setEmail("");
|
||||
setName("");
|
||||
}, [email, name, guests]);
|
||||
|
||||
const handleRemoveGuest = useCallback(
|
||||
(index: number) => {
|
||||
setGuests(guests.filter((_, i) => i !== index));
|
||||
},
|
||||
[guests]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
|
||||
if (guests.length === 0) {
|
||||
Alert.alert("Error", "Please add at least one guest");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.addGuests(booking.uid, guests);
|
||||
Alert.alert("Success", "Guests added successfully", [{ text: "OK", onPress: onSuccess }]);
|
||||
} catch (error) {
|
||||
Alert.alert("Error", error instanceof Error ? error.message : "Failed to add guests");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, guests, onSuccess]);
|
||||
|
||||
// Expose submit function to parent via ref (same pattern as senior's actionHandlersRef)
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1 bg-[#F2F2F7]"
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Info note */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Guests will receive an email notification about this booking.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Form Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Email input */}
|
||||
<View className="border-b border-gray-100 px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">Email *</Text>
|
||||
<TextInput
|
||||
className="h-10 text-[17px] text-[#000]"
|
||||
placeholder="guest@example.com"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Name input */}
|
||||
<View className="px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">Name (optional)</Text>
|
||||
<TextInput
|
||||
className="h-10 text-[17px] text-[#000]"
|
||||
placeholder="Guest Name"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Add button */}
|
||||
<TouchableOpacity
|
||||
className="mb-6 flex-row items-center justify-center rounded-xl bg-white py-3"
|
||||
onPress={handleAddGuest}
|
||||
activeOpacity={0.7}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Ionicons name="add-circle" size={22} color="#007AFF" />
|
||||
<Text className="ml-2 text-[17px] font-medium text-[#007AFF]">Add Guest</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Guest list */}
|
||||
{guests.length > 0 && (
|
||||
<View>
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Guests to add ({guests.length})
|
||||
</Text>
|
||||
<View className="overflow-hidden rounded-xl bg-white">
|
||||
{guests.map((guest, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`flex-row items-center px-4 py-3 ${
|
||||
index < guests.length - 1 ? "border-b border-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="person" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] text-[#000]">{guest.email}</Text>
|
||||
{guest.name && (
|
||||
<Text className="mt-0.5 text-[15px] text-gray-500">{guest.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleRemoveGuest(index)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Ionicons name="close-circle-outline" size={24} color="#FF3B30" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Default export for React Native compatibility
|
||||
export default AddGuestsScreen;
|
||||
@@ -1,29 +1,33 @@
|
||||
import { CalComAPIService, Booking } from "../../services/calcom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useBookingActionModals } from "../../hooks";
|
||||
import { showErrorAlert } from "../../utils/alerts";
|
||||
import { getBookingActions, type BookingActionsResult } from "../../utils/booking-actions";
|
||||
import { openInAppBrowser } from "../../utils/browser";
|
||||
import { getDefaultLocationIconUrl, defaultLocations } from "../../utils/defaultLocations";
|
||||
import { formatAppIdToDisplayName } from "../../utils/formatters";
|
||||
import { getAppIconUrl } from "../../utils/getAppIconUrl";
|
||||
import { shadows } from "../../utils/shadows";
|
||||
import { AppPressable } from "../AppPressable";
|
||||
import { BookingActionsModal } from "../BookingActionsModal";
|
||||
import { FullScreenModal } from "../FullScreenModal";
|
||||
import { SvgImage } from "../SvgImage";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
TextInput,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { View, Text, ScrollView, Alert, ActivityIndicator, Platform } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// 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 },
|
||||
};
|
||||
|
||||
// Format date: "Tuesday, November 25, 2025"
|
||||
const formatDateFull = (dateString: string): string => {
|
||||
if (!dateString) return "";
|
||||
@@ -169,21 +173,164 @@ const getLocationProvider = (location: string | undefined, metadata?: Record<str
|
||||
|
||||
export interface BookingDetailScreenProps {
|
||||
uid: string;
|
||||
/**
|
||||
* Callback to expose internal action handlers to parent component.
|
||||
* Used by iOS header menu to trigger actions like reschedule.
|
||||
*/
|
||||
onActionsReady?: (handlers: {
|
||||
openRescheduleModal: () => void;
|
||||
openEditLocationModal: () => void;
|
||||
openAddGuestsModal: () => void;
|
||||
openViewRecordingsModal: () => void;
|
||||
openMeetingSessionDetailsModal: () => void;
|
||||
openMarkNoShowModal: () => void;
|
||||
handleCancelBooking: () => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function BookingDetailScreen({ uid }: BookingDetailScreenProps) {
|
||||
export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps) {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { userInfo } = useAuth();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showActionsModal, setShowActionsModal] = useState(false);
|
||||
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
|
||||
const [rescheduleDate, setRescheduleDate] = useState("");
|
||||
const [rescheduleTime, setRescheduleTime] = useState("");
|
||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||
const [rescheduling, setRescheduling] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
// Compute actions using centralized gating
|
||||
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]);
|
||||
|
||||
// Cancel booking handler (needs to be defined before useEffect that exposes it)
|
||||
const performCancelBooking = useCallback(
|
||||
async (reason: string) => {
|
||||
if (!booking) return;
|
||||
|
||||
setIsCancelling(true);
|
||||
setShowActionsModal(false);
|
||||
|
||||
try {
|
||||
await CalComAPIService.cancelBooking(booking.uid, reason);
|
||||
Alert.alert("Success", "Booking cancelled successfully", [
|
||||
{
|
||||
text: "OK",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel booking");
|
||||
if (__DEV__) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.debug("[BookingDetailScreen] cancelBooking failed", { message });
|
||||
}
|
||||
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
},
|
||||
[booking, router]
|
||||
);
|
||||
|
||||
const handleCancelBooking = useCallback(() => {
|
||||
if (!booking) return;
|
||||
|
||||
Alert.alert("Cancel Booking", `Are you sure you want to cancel "${booking.title}"?`, [
|
||||
{ text: "No", style: "cancel" },
|
||||
{
|
||||
text: "Yes, Cancel",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
// Prompt for cancellation reason (iOS only supports Alert.prompt)
|
||||
if (Platform.OS === "ios") {
|
||||
Alert.prompt(
|
||||
"Cancellation Reason",
|
||||
"Please provide a reason for cancelling this booking:",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Cancel Booking",
|
||||
style: "destructive",
|
||||
onPress: (reason) => {
|
||||
performCancelBooking(reason?.trim() || "Cancelled by host");
|
||||
},
|
||||
},
|
||||
],
|
||||
"plain-text",
|
||||
"",
|
||||
"default"
|
||||
);
|
||||
} else {
|
||||
// For Android, just cancel with default reason
|
||||
performCancelBooking("Cancelled by host");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}, [booking, performCancelBooking]);
|
||||
|
||||
// Navigate to reschedule screen (same pattern as senior's - navigate to screen in same folder)
|
||||
const openRescheduleModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/reschedule",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
|
||||
// Navigate to edit location screen (same pattern as senior's - navigate to screen in same folder)
|
||||
const openEditLocationModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/edit-location",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
|
||||
// Navigate to add guests screen (same pattern as senior's - navigate to screen in same folder)
|
||||
const openAddGuestsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/add-guests",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
|
||||
// Navigate to mark no show screen (same pattern as senior's - navigate to screen in same folder)
|
||||
const openMarkNoShowModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/mark-no-show",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
|
||||
// Navigate to view recordings screen (same pattern as senior's - navigate to screen in same folder)
|
||||
const openViewRecordingsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/view-recordings",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
|
||||
// Navigate to meeting session details screen (same pattern as senior's - navigate to screen in same folder)
|
||||
const openMeetingSessionDetailsModal = useCallback(() => {
|
||||
if (!booking) return;
|
||||
router.push({
|
||||
pathname: "/(tabs)/(bookings)/meeting-session-details",
|
||||
params: { uid: booking.uid },
|
||||
});
|
||||
}, [booking, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
@@ -191,6 +338,31 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) {
|
||||
}
|
||||
}, [uid]);
|
||||
|
||||
// Expose action handlers to parent component (for iOS header menu)
|
||||
useEffect(() => {
|
||||
if (booking && onActionsReady) {
|
||||
onActionsReady({
|
||||
openRescheduleModal,
|
||||
openEditLocationModal,
|
||||
openAddGuestsModal,
|
||||
openViewRecordingsModal,
|
||||
openMeetingSessionDetailsModal,
|
||||
openMarkNoShowModal,
|
||||
handleCancelBooking,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
booking,
|
||||
onActionsReady,
|
||||
openRescheduleModal,
|
||||
openEditLocationModal,
|
||||
openAddGuestsModal,
|
||||
openViewRecordingsModal,
|
||||
openMeetingSessionDetailsModal,
|
||||
handleCancelBooking,
|
||||
openMarkNoShowModal,
|
||||
]);
|
||||
|
||||
const fetchBooking = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -236,81 +408,6 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const openRescheduleModal = () => {
|
||||
if (!booking) return;
|
||||
|
||||
// Pre-fill with the current booking date/time (using local timezone consistently)
|
||||
const currentDate = new Date(booking.startTime);
|
||||
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}-${String(currentDate.getDate()).padStart(2, "0")}`;
|
||||
const timeStr = `${String(currentDate.getHours()).padStart(2, "0")}:${String(
|
||||
currentDate.getMinutes()
|
||||
).padStart(2, "0")}`;
|
||||
|
||||
setRescheduleDate(dateStr);
|
||||
setRescheduleTime(timeStr);
|
||||
setRescheduleReason("");
|
||||
setShowRescheduleModal(true);
|
||||
};
|
||||
|
||||
const handleReschedule = async () => {
|
||||
if (!booking || !rescheduleDate || !rescheduleTime) {
|
||||
showErrorAlert("Error", "Please enter both date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the date and time
|
||||
const dateTimeStr = `${rescheduleDate}T${rescheduleTime}:00`;
|
||||
const newDateTime = new Date(dateTimeStr);
|
||||
|
||||
// Validate the date
|
||||
if (isNaN(newDateTime.getTime())) {
|
||||
showErrorAlert(
|
||||
"Error",
|
||||
"Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the new time is in the future
|
||||
if (newDateTime <= new Date()) {
|
||||
showErrorAlert("Error", "Please select a future date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to UTC ISO string
|
||||
const startUtc = newDateTime.toISOString();
|
||||
|
||||
setRescheduling(true);
|
||||
try {
|
||||
await CalComAPIService.rescheduleBooking(booking.uid, {
|
||||
start: startUtc,
|
||||
reschedulingReason: rescheduleReason.trim() || undefined,
|
||||
});
|
||||
|
||||
Alert.alert("Success", "Booking rescheduled successfully");
|
||||
setShowRescheduleModal(false);
|
||||
setRescheduleDate("");
|
||||
setRescheduleTime("");
|
||||
setRescheduleReason("");
|
||||
|
||||
// Refresh booking data
|
||||
await fetchBooking();
|
||||
} catch (error) {
|
||||
console.error("Failed to reschedule booking");
|
||||
if (__DEV__) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
console.debug("[BookingDetailScreen] rescheduleBooking failed", { message, stack });
|
||||
}
|
||||
showErrorAlert("Error", "Failed to reschedule booking. Please try again.");
|
||||
} finally {
|
||||
setRescheduling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
|
||||
@@ -422,21 +519,45 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) {
|
||||
) : null}
|
||||
{booking.attendees && booking.attendees.length > 0 ? (
|
||||
<View>
|
||||
{booking.attendees.map((attendee, index) => (
|
||||
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
|
||||
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
|
||||
<Text className="text-base font-semibold text-white">
|
||||
{getInitials(attendee.name)}
|
||||
</Text>
|
||||
{booking.attendees.map((attendee, index) => {
|
||||
const isNoShow =
|
||||
(attendee as any).noShow === true || (attendee as any).absent === true;
|
||||
return (
|
||||
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
|
||||
<View
|
||||
className={`mr-3 h-12 w-12 items-center justify-center rounded-full ${
|
||||
isNoShow ? "bg-[#DC2626]" : "bg-black"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-base font-semibold text-white">
|
||||
{getInitials(attendee.name)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<View className="mb-1 flex-row items-center">
|
||||
<Text
|
||||
className={`text-base font-medium ${
|
||||
isNoShow ? "text-[#DC2626]" : "text-[#333]"
|
||||
}`}
|
||||
>
|
||||
{attendee.name}
|
||||
</Text>
|
||||
{isNoShow && (
|
||||
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-2 py-0.5">
|
||||
<Ionicons name="eye-off" size={12} color="#DC2626" />
|
||||
<Text className="ml-1 text-xs font-medium text-[#DC2626]">
|
||||
No-show
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className={`text-sm ${isNoShow ? "text-[#DC2626]" : "text-[#666]"}`}>
|
||||
{attendee.email}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
{attendee.name}
|
||||
</Text>
|
||||
<Text className="text-sm text-[#666]">{attendee.email}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -522,170 +643,30 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) {
|
||||
visible={showActionsModal}
|
||||
onClose={() => setShowActionsModal(false)}
|
||||
booking={booking}
|
||||
hasLocationUrl={!!locationProvider?.url}
|
||||
isUpcoming={booking ? new Date(booking.startTime) > new Date() : false}
|
||||
isPast={booking ? new Date(booking.startTime) <= new Date() : false}
|
||||
isCancelled={booking?.status?.toUpperCase() === "CANCELLED"}
|
||||
isUnconfirmed={booking?.status?.toUpperCase() === "PENDING"}
|
||||
actions={actions}
|
||||
onReschedule={openRescheduleModal}
|
||||
onEditLocation={() => {
|
||||
Alert.alert("Edit Location", "Edit location functionality coming soon");
|
||||
}}
|
||||
onAddGuests={() => {
|
||||
Alert.alert("Add Guests", "Add guests functionality coming soon");
|
||||
}}
|
||||
onViewRecordings={() => {
|
||||
Alert.alert("View Recordings", "View recordings functionality coming soon");
|
||||
}}
|
||||
onMeetingSessionDetails={() => {
|
||||
Alert.alert(
|
||||
"Meeting Session Details",
|
||||
"Meeting session details functionality coming soon"
|
||||
);
|
||||
}}
|
||||
onMarkNoShow={() => {
|
||||
Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon");
|
||||
}}
|
||||
onEditLocation={openEditLocationModal}
|
||||
onAddGuests={openAddGuestsModal}
|
||||
onViewRecordings={openViewRecordingsModal}
|
||||
onMeetingSessionDetails={openMeetingSessionDetailsModal}
|
||||
onMarkNoShow={openMarkNoShowModal}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality coming soon");
|
||||
}}
|
||||
onCancelBooking={() => {
|
||||
Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [
|
||||
{ text: "No", style: "cancel" },
|
||||
{
|
||||
text: "Yes, Cancel",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
// TODO: Implement cancel booking
|
||||
if (__DEV__)
|
||||
console.debug("[BookingDetailScreen] cancel booking (not implemented yet)");
|
||||
},
|
||||
},
|
||||
]);
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
}}
|
||||
onCancelBooking={handleCancelBooking}
|
||||
/>
|
||||
|
||||
{/* Reschedule Modal */}
|
||||
<FullScreenModal
|
||||
visible={showRescheduleModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowRescheduleModal(false)}
|
||||
>
|
||||
<AppPressable
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
onPress={() => setShowRescheduleModal(false)}
|
||||
>
|
||||
<AppPressable
|
||||
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={shadows.xl()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="px-8 pb-4 pt-6">
|
||||
<View className="flex-row items-start space-x-3">
|
||||
<View className="h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#F3F4F6]">
|
||||
<Ionicons name="calendar-outline" size={24} color="#111827" />
|
||||
</View>
|
||||
<View className="flex-1 pt-1">
|
||||
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
|
||||
Reschedule Booking
|
||||
</Text>
|
||||
<Text className="text-sm text-[#6B7280]">
|
||||
Select a new date and time for this booking.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView className="max-h-[400px] px-8 pb-6">
|
||||
{/* Date Input */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-bold text-[#111827]">
|
||||
New Date
|
||||
<Text className="font-normal text-[#6B7280]"> (YYYY-MM-DD)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
|
||||
placeholder="2024-12-25"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={rescheduleDate}
|
||||
onChangeText={setRescheduleDate}
|
||||
editable={!rescheduling}
|
||||
keyboardType="default"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Input */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-bold text-[#111827]">
|
||||
New Time
|
||||
<Text className="font-normal text-[#6B7280]"> (HH:MM, 24-hour)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
|
||||
placeholder="14:30"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={rescheduleTime}
|
||||
onChangeText={setRescheduleTime}
|
||||
editable={!rescheduling}
|
||||
keyboardType="default"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Reason Input */}
|
||||
<View className="mb-2">
|
||||
<Text className="mb-2 text-sm font-bold text-[#111827]">
|
||||
Reason
|
||||
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={rescheduleReason}
|
||||
onChangeText={setRescheduleReason}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
editable={!rescheduling}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
|
||||
<View className="flex-row justify-end gap-2 space-x-2">
|
||||
<AppPressable
|
||||
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
|
||||
onPress={() => {
|
||||
setShowRescheduleModal(false);
|
||||
setRescheduleDate("");
|
||||
setRescheduleTime("");
|
||||
setRescheduleReason("");
|
||||
}}
|
||||
disabled={rescheduling}
|
||||
>
|
||||
<Text className="text-base font-medium text-[#374151]">Cancel</Text>
|
||||
</AppPressable>
|
||||
<AppPressable
|
||||
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${
|
||||
rescheduling ? "opacity-60" : ""
|
||||
}`}
|
||||
onPress={handleReschedule}
|
||||
disabled={rescheduling}
|
||||
>
|
||||
{rescheduling ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Text className="text-base font-medium text-white">Reschedule</Text>
|
||||
)}
|
||||
</AppPressable>
|
||||
</View>
|
||||
</View>
|
||||
</AppPressable>
|
||||
</AppPressable>
|
||||
</FullScreenModal>
|
||||
{/* Cancelling overlay */}
|
||||
{isCancelling && (
|
||||
<View className="absolute inset-0 items-center justify-center bg-black/50">
|
||||
<View className="rounded-2xl bg-white px-8 py-6">
|
||||
<ActivityIndicator size="large" color="#000" />
|
||||
<Text className="mt-3 text-base font-medium text-gray-700">
|
||||
Cancelling booking...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* EditLocationScreen Component (iOS)
|
||||
*
|
||||
* iOS-specific implementation with native context menu for location type selection.
|
||||
* Uses @expo/ui/swift-ui ContextMenu for the glass UI feel.
|
||||
*/
|
||||
import type { Booking } from "../../services/calcom";
|
||||
import { CalComAPIService } from "../../services/calcom";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Host, ContextMenu, Button, Image, HStack } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from "react";
|
||||
import { View, Text, TextInput, Alert, ScrollView, KeyboardAvoidingView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// Location types configuration
|
||||
export const LOCATION_TYPES = {
|
||||
link: {
|
||||
id: "link",
|
||||
label: "Meeting Link",
|
||||
description: "Video call URL or web link",
|
||||
iconName: "link" as const,
|
||||
sfSymbol: "link" as const,
|
||||
placeholder: "https://meet.example.com/your-meeting",
|
||||
keyboardType: "url" as const,
|
||||
inputType: "single" as const,
|
||||
},
|
||||
phone: {
|
||||
id: "phone",
|
||||
label: "Phone Call",
|
||||
description: "Phone number for the meeting",
|
||||
iconName: "call" as const,
|
||||
sfSymbol: "phone" as const,
|
||||
placeholder: "+1 234-567-8900 (include country code)",
|
||||
keyboardType: "phone-pad" as const,
|
||||
inputType: "single" as const,
|
||||
},
|
||||
address: {
|
||||
id: "address",
|
||||
label: "Location / Other",
|
||||
description: "Address, place, or custom text",
|
||||
iconName: "location" as const,
|
||||
sfSymbol: "mappin.and.ellipse" as const,
|
||||
placeholder: "Enter address or location details",
|
||||
keyboardType: "default" as const,
|
||||
inputType: "multiline" as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type LocationTypeId = keyof typeof LOCATION_TYPES;
|
||||
|
||||
export interface EditLocationScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
}
|
||||
|
||||
// Handle type for parent component to call submit
|
||||
export interface EditLocationScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
// Helper to detect location type from existing location string
|
||||
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.")
|
||||
) {
|
||||
return "link";
|
||||
}
|
||||
|
||||
// Check if it looks like a phone number
|
||||
const phoneRegex = /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/;
|
||||
if (phoneRegex.test(location.replace(/\s/g, ""))) {
|
||||
return "phone";
|
||||
}
|
||||
|
||||
// Default to address for other text
|
||||
return "address";
|
||||
};
|
||||
|
||||
export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocationScreenProps>(
|
||||
function EditLocationScreen({ booking, onSuccess, onSavingChange }, ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [selectedType, setSelectedType] = useState<LocationTypeId>("link");
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Pre-fill with current location
|
||||
useEffect(() => {
|
||||
if (booking?.location) {
|
||||
const detectedType = detectLocationType(booking.location);
|
||||
setSelectedType(detectedType);
|
||||
setInputValue(booking.location);
|
||||
}
|
||||
}, [booking?.location]);
|
||||
|
||||
// Notify parent of saving state changes
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const selectedTypeConfig = LOCATION_TYPES[selectedType];
|
||||
|
||||
const handleTypeSelect = useCallback(
|
||||
(type: LocationTypeId) => {
|
||||
if (selectedType !== type) {
|
||||
setInputValue("");
|
||||
}
|
||||
setSelectedType(type);
|
||||
},
|
||||
[selectedType]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
Alert.alert("Error", "Please enter a location");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate phone number format
|
||||
if (selectedType === "phone") {
|
||||
if (!trimmedValue.startsWith("+")) {
|
||||
Alert.alert(
|
||||
"Invalid Phone Number",
|
||||
"Phone number must include country code (e.g., +1 234-567-8900)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the location payload based on type
|
||||
let locationPayload: { type: string; [key: string]: string };
|
||||
|
||||
switch (selectedType) {
|
||||
case "link":
|
||||
locationPayload = { type: "link", link: trimmedValue };
|
||||
break;
|
||||
case "phone":
|
||||
locationPayload = { type: "phone", phone: trimmedValue };
|
||||
break;
|
||||
case "address":
|
||||
default:
|
||||
locationPayload = { type: "address", address: trimmedValue };
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateLocationV2(booking.uid, locationPayload);
|
||||
Alert.alert("Success", "Location updated successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("[EditLocationScreen] Failed to update location:", error);
|
||||
Alert.alert("Error", error instanceof Error ? error.message : "Failed to update location");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedType, inputValue, onSuccess]);
|
||||
|
||||
// Expose submit function to parent via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" className="flex-1 bg-[#F2F2F7]">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Current Location Info */}
|
||||
{booking.location && (
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="location" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Current Location</Text>
|
||||
<Text className="mt-0.5 text-[15px] text-[#000]" numberOfLines={2}>
|
||||
{booking.location}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Location Type Selector with Native Context Menu */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Location Type
|
||||
</Text>
|
||||
<View className="mb-4 flex-row items-center rounded-xl bg-white px-4">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
|
||||
<Ionicons name={selectedTypeConfig.iconName} size={20} color="#007AFF" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-[#000]">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">
|
||||
{selectedTypeConfig.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Native iOS Context Menu Button */}
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[
|
||||
buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"),
|
||||
padding(),
|
||||
]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.link.sfSymbol}
|
||||
onPress={() => handleTypeSelect("link")}
|
||||
label={LOCATION_TYPES.link.label}
|
||||
/>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.phone.sfSymbol}
|
||||
onPress={() => handleTypeSelect("phone")}
|
||||
label={LOCATION_TYPES.phone.label}
|
||||
/>
|
||||
<Button
|
||||
systemImage={LOCATION_TYPES.address.sfSymbol}
|
||||
onPress={() => handleTypeSelect("address")}
|
||||
label={LOCATION_TYPES.address.label}
|
||||
/>
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image
|
||||
systemName="ellipsis"
|
||||
color="primary"
|
||||
size={20}
|
||||
modifiers={[frame({ height: 18, width: 18 })]}
|
||||
/>
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Location Input */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<TextInput
|
||||
className={`px-4 py-3 text-[17px] text-[#000] ${
|
||||
selectedTypeConfig.inputType === "multiline" ? "min-h-[100px]" : "h-[50px]"
|
||||
}`}
|
||||
placeholder={selectedTypeConfig.placeholder}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
keyboardType={selectedTypeConfig.keyboardType}
|
||||
multiline={selectedTypeConfig.inputType === "multiline"}
|
||||
textAlignVertical={selectedTypeConfig.inputType === "multiline" ? "top" : "center"}
|
||||
autoCapitalize={selectedType === "link" ? "none" : "sentences"}
|
||||
autoCorrect={selectedType !== "link" && selectedType !== "phone"}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Info note */}
|
||||
<View className="flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Updating the location will notify attendees. Note: Calendar events may not be updated
|
||||
automatically.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Default export for React Native compatibility
|
||||
export default EditLocationScreen;
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* EditLocationScreen Component (Android/Web)
|
||||
*
|
||||
* Screen content for editing the location of a booking.
|
||||
* Uses a dropdown modal for location type selection on Android/Web.
|
||||
* iOS uses a native header menu via EditLocationScreen.ios.tsx
|
||||
*
|
||||
* Note: The API currently only supports these location types:
|
||||
* - address (physical address)
|
||||
* - link (custom meeting URL)
|
||||
* - phone (phone number)
|
||||
*
|
||||
* Integration-based locations (Cal Video, Google Meet, Zoom) are NOT
|
||||
* supported for updating existing bookings via the current API.
|
||||
*/
|
||||
import type { Booking } from "../../services/calcom";
|
||||
import { CalComAPIService } from "../../services/calcom";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Alert,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
FlatList,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export const LOCATION_TYPES = {
|
||||
link: {
|
||||
id: "link" as const,
|
||||
label: "Meeting Link",
|
||||
description: "Video call URL or web link",
|
||||
iconName: "link" as const,
|
||||
sfSymbol: "link" as const,
|
||||
placeholder: "https://meet.example.com/your-meeting",
|
||||
keyboardType: "url" as const,
|
||||
inputType: "single" as const,
|
||||
},
|
||||
phone: {
|
||||
id: "phone" as const,
|
||||
label: "Phone Call",
|
||||
description: "Phone number for the meeting",
|
||||
iconName: "call" as const,
|
||||
sfSymbol: "phone" as const,
|
||||
placeholder: "+1 234-567-8900 (include country code)",
|
||||
keyboardType: "phone-pad" as const,
|
||||
inputType: "single" as const,
|
||||
},
|
||||
address: {
|
||||
id: "address" as const,
|
||||
label: "Location / Other",
|
||||
description: "Address, place, or custom text",
|
||||
iconName: "location" as const,
|
||||
sfSymbol: "mappin.and.ellipse" as const,
|
||||
placeholder: "Enter address or location details",
|
||||
keyboardType: "default" as const,
|
||||
inputType: "multiline" as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const LOCATION_TYPES_ARRAY = Object.values(LOCATION_TYPES);
|
||||
|
||||
export type LocationTypeId = keyof typeof LOCATION_TYPES;
|
||||
|
||||
export interface EditLocationScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
selectedType?: LocationTypeId;
|
||||
onTypeChange?: (type: LocationTypeId) => void;
|
||||
}
|
||||
|
||||
export interface EditLocationScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
const detectLocationType = (location: string): LocationTypeId => {
|
||||
if (!location) return "link";
|
||||
|
||||
if (
|
||||
location.startsWith("http://") ||
|
||||
location.startsWith("https://") ||
|
||||
location.startsWith("www.")
|
||||
) {
|
||||
return "link";
|
||||
}
|
||||
|
||||
const phoneRegex = /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/;
|
||||
if (phoneRegex.test(location.replace(/\s/g, ""))) {
|
||||
return "phone";
|
||||
}
|
||||
|
||||
return "address";
|
||||
};
|
||||
|
||||
export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocationScreenProps>(
|
||||
function EditLocationScreen({ booking, onSuccess, onSavingChange }, ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [selectedType, setSelectedType] = useState<LocationTypeId>("link");
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [showTypePicker, setShowTypePicker] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (booking?.location) {
|
||||
const detectedType = detectLocationType(booking.location);
|
||||
setSelectedType(detectedType);
|
||||
setInputValue(booking.location);
|
||||
}
|
||||
}, [booking?.location]);
|
||||
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const selectedTypeConfig = LOCATION_TYPES[selectedType];
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
Alert.alert("Error", "Please enter a location");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType === "phone") {
|
||||
if (!trimmedValue.startsWith("+")) {
|
||||
Alert.alert(
|
||||
"Invalid Phone Number",
|
||||
"Phone number must include country code (e.g., +1 234-567-8900)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the location payload based on type
|
||||
let locationPayload: { type: string; [key: string]: string };
|
||||
|
||||
switch (selectedType) {
|
||||
case "link":
|
||||
locationPayload = { type: "link", link: trimmedValue };
|
||||
break;
|
||||
case "phone":
|
||||
locationPayload = { type: "phone", phone: trimmedValue };
|
||||
break;
|
||||
case "address":
|
||||
default:
|
||||
locationPayload = { type: "address", address: trimmedValue };
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateLocationV2(booking.uid, locationPayload);
|
||||
Alert.alert("Success", "Location updated successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("[EditLocationScreen] Failed to update location:", error);
|
||||
Alert.alert("Error", error instanceof Error ? error.message : "Failed to update location");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedType, inputValue, onSuccess]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1 bg-[#F2F2F7]"
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Current Location Info */}
|
||||
{booking.location && (
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="location" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Current Location</Text>
|
||||
<Text className="mt-0.5 text-[15px] text-[#000]" numberOfLines={2}>
|
||||
{booking.location}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Location Type Selector */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Location Type
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="mb-4 flex-row items-center rounded-xl bg-white px-4 py-3.5"
|
||||
onPress={() => setShowTypePicker(true)}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-lg bg-[#007AFF]/10">
|
||||
<Ionicons name={selectedTypeConfig.iconName} size={22} color="#007AFF" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-[#000]">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">
|
||||
{selectedTypeConfig.description}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-down" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Location Input */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
{selectedTypeConfig.label}
|
||||
</Text>
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<TextInput
|
||||
className={`px-4 py-3 text-[17px] text-[#000] ${
|
||||
selectedTypeConfig.inputType === "multiline" ? "min-h-[100px]" : "h-[50px]"
|
||||
}`}
|
||||
placeholder={selectedTypeConfig.placeholder}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
keyboardType={selectedTypeConfig.keyboardType}
|
||||
multiline={selectedTypeConfig.inputType === "multiline"}
|
||||
textAlignVertical={selectedTypeConfig.inputType === "multiline" ? "top" : "center"}
|
||||
autoCapitalize={selectedType === "link" ? "none" : "sentences"}
|
||||
autoCorrect={selectedType !== "link" && selectedType !== "phone"}
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Info note */}
|
||||
<View className="flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Updating the location will notify attendees. Note: Calendar events may not be
|
||||
updated automatically.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Location Type Picker Modal */}
|
||||
<Modal
|
||||
visible={showTypePicker}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowTypePicker(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowTypePicker(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
className="max-h-[70%] w-[320px] overflow-hidden rounded-2xl bg-white"
|
||||
>
|
||||
<View className="border-b border-gray-200 px-4 py-3">
|
||||
<Text className="text-center text-[17px] font-semibold text-[#000]">
|
||||
Select Location Type
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={LOCATION_TYPES_ARRAY}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = item.id === selectedType;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-4 py-3.5 ${
|
||||
isSelected ? "bg-[#EBF4FF]" : ""
|
||||
}`}
|
||||
onPress={() => {
|
||||
setSelectedType(item.id);
|
||||
if (selectedType !== item.id) {
|
||||
setInputValue("");
|
||||
}
|
||||
setShowTypePicker(false);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`mr-3 h-10 w-10 items-center justify-center rounded-lg ${
|
||||
isSelected ? "bg-[#007AFF]/20" : "bg-[#F2F2F7]"
|
||||
}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={item.iconName}
|
||||
size={22}
|
||||
color={isSelected ? "#007AFF" : "#6B7280"}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
isSelected ? "font-semibold text-[#007AFF]" : "text-[#000]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">{item.description}</Text>
|
||||
</View>
|
||||
{isSelected && <Ionicons name="checkmark" size={22} color="#007AFF" />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="border-t border-gray-200 py-3"
|
||||
onPress={() => setShowTypePicker(false)}
|
||||
>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#007AFF]">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default EditLocationScreen;
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MarkNoShowScreen Component
|
||||
*
|
||||
* Screen content for marking attendees as no-show for a booking.
|
||||
* Used with the mark-no-show route that has native Stack.Header.
|
||||
*/
|
||||
import type { Booking } from "../../services/calcom";
|
||||
import { CalComAPIService } from "../../services/calcom";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { View, Text, TouchableOpacity, ActivityIndicator, FlatList, Alert } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
interface Attendee {
|
||||
id?: number | string;
|
||||
email: string;
|
||||
name: string;
|
||||
noShow?: boolean;
|
||||
}
|
||||
|
||||
export interface MarkNoShowScreenProps {
|
||||
booking: Booking | null;
|
||||
attendees: Attendee[];
|
||||
onUpdate: (attendees: Attendee[]) => void;
|
||||
onBookingUpdate?: (booking: Booking) => void;
|
||||
}
|
||||
|
||||
const getInitials = (name: string): string => {
|
||||
if (!name) return "?";
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) {
|
||||
return parts[0].charAt(0).toUpperCase();
|
||||
}
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||
};
|
||||
|
||||
const maskEmail = (email: string): string => {
|
||||
if (!email || !email.includes("@")) return "***";
|
||||
const [localPart, domain] = email.split("@");
|
||||
if (localPart.length <= 2) {
|
||||
return `${localPart[0]}***@${domain}`;
|
||||
}
|
||||
return `${localPart.substring(0, 2)}***@${domain}`;
|
||||
};
|
||||
|
||||
export function MarkNoShowScreen({
|
||||
booking,
|
||||
attendees,
|
||||
onUpdate,
|
||||
onBookingUpdate,
|
||||
}: MarkNoShowScreenProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeAttendees = Array.isArray(attendees) ? attendees : [];
|
||||
const [processingEmail, setProcessingEmail] = useState<string | null>(null);
|
||||
|
||||
const handleMarkNoShow = async (attendee: Attendee) => {
|
||||
if (!booking) return;
|
||||
|
||||
const isCurrentlyNoShow = attendee.noShow === true;
|
||||
const action = isCurrentlyNoShow ? "unmark no-show" : "mark no-show";
|
||||
|
||||
Alert.alert(
|
||||
isCurrentlyNoShow ? "Unmark No-Show" : "Mark No-Show",
|
||||
`Are you sure you want to ${action} ${attendee.name || attendee.email}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Confirm",
|
||||
style: isCurrentlyNoShow ? "default" : "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
setProcessingEmail(attendee.email);
|
||||
const updatedBooking = await CalComAPIService.markAbsent(
|
||||
booking.uid,
|
||||
attendee.email,
|
||||
!isCurrentlyNoShow
|
||||
);
|
||||
|
||||
// API returns "absent" field, not "noShow"
|
||||
const updatedAttendees: Attendee[] = [];
|
||||
if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
|
||||
updatedBooking.attendees.forEach((att: any) => {
|
||||
updatedAttendees.push({
|
||||
id: att.id,
|
||||
email: att.email,
|
||||
name: att.name || att.email,
|
||||
noShow: att.absent === true || att.noShow === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onUpdate(updatedAttendees);
|
||||
|
||||
if (onBookingUpdate) {
|
||||
onBookingUpdate(updatedBooking);
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
"Success",
|
||||
`${attendee.name || attendee.email} has been ${
|
||||
isCurrentlyNoShow ? "unmarked as no-show" : "marked as no-show"
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[MarkNoShowScreen] Failed to mark no-show:", error);
|
||||
if (__DEV__) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
console.debug("[MarkNoShowScreen] Error details:", {
|
||||
message,
|
||||
stack,
|
||||
attendeeEmail: maskEmail(attendee.email),
|
||||
bookingUid: booking.uid,
|
||||
absent: !isCurrentlyNoShow,
|
||||
});
|
||||
}
|
||||
Alert.alert("Error", error instanceof Error ? error.message : `Failed to ${action}`);
|
||||
} finally {
|
||||
setProcessingEmail(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const renderAttendee = ({ item, index }: { item: Attendee; index: number }) => {
|
||||
const isNoShow = item.noShow === true;
|
||||
const isProcessing = processingEmail === item.email;
|
||||
const isLast = index === safeAttendees.length - 1;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-4 py-4 ${!isLast ? "border-b border-gray-100" : ""} ${
|
||||
isNoShow ? "bg-[#FEF2F2]" : "bg-white"
|
||||
}`}
|
||||
onPress={() => handleMarkNoShow(item)}
|
||||
disabled={isProcessing}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
className={`mr-3 h-12 w-12 items-center justify-center rounded-full ${
|
||||
isNoShow ? "bg-[#FECACA]" : "bg-[#E8E8ED]"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[16px] font-semibold ${isNoShow ? "text-[#DC2626]" : "text-gray-600"}`}
|
||||
>
|
||||
{getInitials(item.name)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mr-3 flex-1">
|
||||
<Text
|
||||
className={`text-[17px] font-medium ${isNoShow ? "text-[#991B1B]" : "text-[#000]"}`}
|
||||
>
|
||||
{item.name || "Unknown"}
|
||||
</Text>
|
||||
<Text className={`mt-0.5 text-[15px] ${isNoShow ? "text-[#DC2626]" : "text-gray-500"}`}>
|
||||
{item.email}
|
||||
</Text>
|
||||
{isNoShow && (
|
||||
<View className="mt-1.5 flex-row items-center">
|
||||
<Ionicons name="eye-off" size={12} color="#DC2626" />
|
||||
<Text className="ml-1 text-[13px] font-medium text-[#DC2626]">Marked as no-show</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{isProcessing ? (
|
||||
<ActivityIndicator size="small" color="#6B7280" />
|
||||
) : (
|
||||
<View className="flex-row items-center">
|
||||
<View
|
||||
className={`flex-row items-center rounded-full px-3.5 py-2 ${
|
||||
isNoShow
|
||||
? "border border-[#16A34A] bg-white"
|
||||
: "border border-[#FEE2E2] bg-[#FEE2E2]"
|
||||
}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={isNoShow ? "eye" : "eye-off"}
|
||||
size={15}
|
||||
color={isNoShow ? "#16A34A" : "#DC2626"}
|
||||
style={{ marginRight: 5 }}
|
||||
/>
|
||||
<Text
|
||||
className={`text-[14px] font-semibold ${
|
||||
isNoShow ? "text-[#16A34A]" : "text-[#DC2626]"
|
||||
}`}
|
||||
>
|
||||
{isNoShow ? "Unmark" : "Mark"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#F2F2F7]">
|
||||
<View className="flex-1 p-4">
|
||||
{/* Info note */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Tap an attendee to mark them as no-show or to undo a previous no-show marking.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{safeAttendees.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<View className="mb-4 h-20 w-20 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="people" size={40} color="#9CA3AF" />
|
||||
</View>
|
||||
<Text className="text-[17px] font-medium text-gray-700">No attendees found</Text>
|
||||
<Text className="mt-2 max-w-[280px] text-center text-[15px] text-gray-500">
|
||||
Attendee information is not available for this booking.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Attendees ({safeAttendees.length})
|
||||
</Text>
|
||||
<View className="overflow-hidden rounded-xl">
|
||||
<FlatList
|
||||
data={safeAttendees}
|
||||
renderItem={renderAttendee}
|
||||
keyExtractor={(item) => item.email}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export for React Native compatibility
|
||||
export default MarkNoShowScreen;
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* MeetingSessionDetailsScreen Component
|
||||
*
|
||||
* Screen content for viewing meeting session details of a Cal Video booking.
|
||||
* Used with the meeting-session-details route that has native Stack.Header.
|
||||
*/
|
||||
import type { ConferencingSession } from "../../services/types/bookings.types";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { View, Text, FlatList } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export interface MeetingSessionDetailsScreenProps {
|
||||
sessions: ConferencingSession[];
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (!seconds) return "Unknown";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes === 0) return `${remainingSeconds}s`;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function MeetingSessionDetailsScreen({ sessions }: MeetingSessionDetailsScreenProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const renderSession = ({ item, index }: { item: ConferencingSession; index: number }) => (
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Session header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-100 p-4">
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="videocam" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<Text className="text-[17px] font-semibold text-[#000]">
|
||||
Session {sessions.length > 1 ? index + 1 : ""}
|
||||
</Text>
|
||||
</View>
|
||||
{item.duration && (
|
||||
<View className="rounded-full bg-[#E8E8ED] px-3 py-1">
|
||||
<Text className="text-[13px] font-medium text-gray-600">
|
||||
{formatDuration(item.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Session details */}
|
||||
<View className="p-4">
|
||||
<View className="mb-2 flex-row items-center">
|
||||
<Ionicons name="time-outline" size={18} color="#8E8E93" />
|
||||
<Text className="ml-2 text-[15px] text-gray-600">
|
||||
Started: {formatDate(item.startTime)}
|
||||
</Text>
|
||||
</View>
|
||||
{item.endTime && (
|
||||
<View className="mb-2 flex-row items-center">
|
||||
<Ionicons name="time-outline" size={18} color="#8E8E93" />
|
||||
<Text className="ml-2 text-[15px] text-gray-600">
|
||||
Ended: {formatTime(item.endTime)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.maxParticipants && (
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="people-outline" size={18} color="#8E8E93" />
|
||||
<Text className="ml-2 text-[15px] text-gray-600">
|
||||
Max participants: {item.maxParticipants}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Participants */}
|
||||
{item.participants && item.participants.length > 0 && (
|
||||
<View className="border-t border-gray-100 p-4">
|
||||
<Text className="mb-3 text-[13px] font-semibold uppercase tracking-wide text-gray-500">
|
||||
Participants ({item.participants.length})
|
||||
</Text>
|
||||
{item.participants.map((participant, pIndex) => (
|
||||
<View
|
||||
key={participant.id || pIndex}
|
||||
className="mb-2 flex-row items-center rounded-xl bg-[#F2F2F7] p-3"
|
||||
>
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="person" size={18} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[15px] font-medium text-[#000]">
|
||||
{participant.userName || "Unknown participant"}
|
||||
</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">
|
||||
Joined: {formatTime(participant.joinTime)}
|
||||
{participant.duration && ` • ${formatDuration(participant.duration)}`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7] px-8">
|
||||
<View className="mb-4 h-16 w-16 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="information-circle" size={32} color="#8E8E93" />
|
||||
</View>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#000]">
|
||||
No session details available
|
||||
</Text>
|
||||
<Text className="mt-2 text-center text-[15px] leading-5 text-gray-500">
|
||||
Session details may not be available for all meetings.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#F2F2F7]">
|
||||
<View className="flex-1 p-4" style={{ paddingBottom: insets.bottom }}>
|
||||
<FlatList
|
||||
data={sessions}
|
||||
renderItem={renderSession}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export for React Native compatibility
|
||||
export default MeetingSessionDetailsScreen;
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* RescheduleScreen Component
|
||||
*
|
||||
* Screen content for rescheduling a booking.
|
||||
* Used with the reschedule route that has native Stack.Header.
|
||||
*/
|
||||
import type { Booking } from "../../services/calcom";
|
||||
import { CalComAPIService } from "../../services/calcom";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Alert,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// Note: @expo/ui DateTimePicker components are not yet stable
|
||||
// Using a simple inline picker approach instead
|
||||
|
||||
export interface RescheduleScreenProps {
|
||||
booking: Booking | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
}
|
||||
|
||||
// Handle type for parent component to call submit
|
||||
export interface RescheduleScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScreenProps>(
|
||||
function RescheduleScreen({ booking, onSuccess, onSavingChange }, ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Pre-fill with current booking date/time
|
||||
useEffect(() => {
|
||||
if (booking?.startTime) {
|
||||
const bookingDate = new Date(booking.startTime);
|
||||
if (!isNaN(bookingDate.getTime())) {
|
||||
setSelectedDateTime(bookingDate);
|
||||
setReason("");
|
||||
}
|
||||
}
|
||||
}, [booking?.startTime]);
|
||||
|
||||
// Notify parent of saving state changes
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
|
||||
// Validate the date is in the future
|
||||
if (selectedDateTime <= new Date()) {
|
||||
Alert.alert("Error", "Please select a future date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.rescheduleBooking(booking.uid, {
|
||||
start: selectedDateTime.toISOString(),
|
||||
reschedulingReason: reason.trim() || undefined,
|
||||
});
|
||||
Alert.alert("Success", "Booking rescheduled successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("[RescheduleScreen] Failed to reschedule:", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
error instanceof Error ? error.message : "Failed to reschedule booking"
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedDateTime, reason, onSuccess]);
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
// Format time for display
|
||||
const formattedTime = selectedDateTime.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// Expose submit function to parent via ref (same pattern as senior's actionHandlersRef)
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7]">
|
||||
<Text className="text-gray-500">No booking data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate date options for picker (next 90 days)
|
||||
const dateOptions = React.useMemo(() => {
|
||||
const options = [];
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
options.push({
|
||||
label: date.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
value: date,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
// Generate time options (every 15 minutes)
|
||||
const timeOptions = React.useMemo(() => {
|
||||
const options = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const time = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
options.push({
|
||||
label: time,
|
||||
value: { hour, minute },
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1 bg-[#F2F2F7]"
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Booking Title Card */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="calendar" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
|
||||
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Form Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Date picker */}
|
||||
<TouchableOpacity
|
||||
className="border-b border-gray-100 px-4 py-3"
|
||||
onPress={() => {
|
||||
console.log("[RescheduleScreen] Opening date picker");
|
||||
setShowDatePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">New Date</Text>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="h-10 text-[17px] text-[#000]" style={{ lineHeight: 40 }}>
|
||||
{formattedDate}
|
||||
</Text>
|
||||
<Ionicons name="calendar-outline" size={20} color="#007AFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Time picker */}
|
||||
<TouchableOpacity
|
||||
className="border-b border-gray-100 px-4 py-3"
|
||||
onPress={() => {
|
||||
console.log("[RescheduleScreen] Opening time picker");
|
||||
setShowTimePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">New Time</Text>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="h-10 text-[17px] text-[#000]" style={{ lineHeight: 40 }}>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
<Ionicons name="time-outline" size={20} color="#007AFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Reason input */}
|
||||
<View className="px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
|
||||
Reason (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Info note */}
|
||||
<View className="flex-row items-start rounded-xl bg-[#E3F2FD] p-4">
|
||||
<Ionicons name="information-circle" size={20} color="#1976D2" />
|
||||
<Text className="ml-3 flex-1 text-[15px] leading-5 text-[#1565C0]">
|
||||
Attendees will receive an email notification about the new time.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Date Picker Modal - Center Dialog */}
|
||||
<Modal
|
||||
visible={showDatePicker}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowDatePicker(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowDatePicker(false)}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1}>
|
||||
<View className="w-[320px] overflow-hidden rounded-2xl bg-white">
|
||||
<Text className="border-b border-gray-200 py-3 text-center text-[17px] font-semibold text-[#000]">
|
||||
Select Date
|
||||
</Text>
|
||||
<ScrollView
|
||||
style={{ maxHeight: 280 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingVertical: 4 }}
|
||||
>
|
||||
{dateOptions.map((option, index) => {
|
||||
const isSelected =
|
||||
option.value.toDateString() === selectedDateTime.toDateString();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
className={`px-4 py-3 ${isSelected ? "bg-[#E8F4FD]" : ""}`}
|
||||
onPress={() => {
|
||||
const newDate = new Date(option.value);
|
||||
newDate.setHours(selectedDateTime.getHours());
|
||||
newDate.setMinutes(selectedDateTime.getMinutes());
|
||||
setSelectedDateTime(newDate);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
isSelected ? "font-semibold text-[#007AFF]" : "text-[#000]"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{isSelected && <Ionicons name="checkmark" size={22} color="#007AFF" />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
className="border-t border-gray-200 py-3"
|
||||
onPress={() => setShowDatePicker(false)}
|
||||
>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#007AFF]">Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* Time Picker Modal - Center Dialog */}
|
||||
<Modal
|
||||
visible={showTimePicker}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowTimePicker(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowTimePicker(false)}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1}>
|
||||
<View className="w-[320px] overflow-hidden rounded-2xl bg-white">
|
||||
<Text className="border-b border-gray-200 py-3 text-center text-[17px] font-semibold text-[#000]">
|
||||
Select Time
|
||||
</Text>
|
||||
<ScrollView
|
||||
style={{ maxHeight: 280 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingVertical: 4 }}
|
||||
>
|
||||
{timeOptions.map((option, index) => {
|
||||
const isSelected =
|
||||
selectedDateTime.getHours() === option.value.hour &&
|
||||
selectedDateTime.getMinutes() === option.value.minute;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
className={`px-4 py-3 ${isSelected ? "bg-[#E8F4FD]" : ""}`}
|
||||
onPress={() => {
|
||||
const newDate = new Date(selectedDateTime);
|
||||
newDate.setHours(option.value.hour);
|
||||
newDate.setMinutes(option.value.minute);
|
||||
setSelectedDateTime(newDate);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
isSelected ? "font-semibold text-[#007AFF]" : "text-[#000]"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{isSelected && <Ionicons name="checkmark" size={22} color="#007AFF" />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
className="border-t border-gray-200 py-3"
|
||||
onPress={() => setShowTimePicker(false)}
|
||||
>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#007AFF]">Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Default export for React Native compatibility
|
||||
export default RescheduleScreen;
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* ViewRecordingsScreen Component
|
||||
*
|
||||
* Screen content for viewing recordings of a Cal Video booking.
|
||||
* Used with the view-recordings route that has native Stack.Header.
|
||||
*/
|
||||
import type { BookingRecording } from "../../services/types/bookings.types";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, FlatList } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export interface ViewRecordingsScreenProps {
|
||||
recordings: BookingRecording[];
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (!seconds) return "Unknown duration";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes === 0) return `${remainingSeconds}s`;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function ViewRecordingsScreen({ recordings }: ViewRecordingsScreenProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handleOpenRecording = async (recording: BookingRecording) => {
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(recording.downloadUrl, {
|
||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
|
||||
});
|
||||
} catch {
|
||||
console.error("Failed to open recording");
|
||||
}
|
||||
};
|
||||
|
||||
const renderRecording = ({ item }: { item: BookingRecording }) => (
|
||||
<TouchableOpacity
|
||||
className="mb-3 overflow-hidden rounded-xl bg-white"
|
||||
onPress={() => handleOpenRecording(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="flex-row items-center p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="videocam" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-[#000]">Recording</Text>
|
||||
<Text className="mt-0.5 text-[15px] text-gray-500">{formatDate(item.startTime)}</Text>
|
||||
{item.duration && (
|
||||
<Text className="mt-0.5 text-[13px] text-gray-400">
|
||||
Duration: {formatDuration(item.duration)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (recordings.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#F2F2F7] px-8">
|
||||
<View className="mb-4 h-16 w-16 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="videocam-off" size={32} color="#8E8E93" />
|
||||
</View>
|
||||
<Text className="text-center text-[17px] font-semibold text-[#000]">
|
||||
No recordings available
|
||||
</Text>
|
||||
<Text className="mt-2 text-center text-[15px] leading-5 text-gray-500">
|
||||
Recordings may take some time to process after the meeting ends.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#F2F2F7]">
|
||||
<View className="flex-1 p-4" style={{ paddingBottom: insets.bottom }}>
|
||||
<FlatList
|
||||
data={recordings}
|
||||
renderItem={renderRecording}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export for React Native compatibility
|
||||
export default ViewRecordingsScreen;
|
||||
@@ -73,6 +73,7 @@ export {
|
||||
} from "./useActiveBookingFilter";
|
||||
|
||||
export { useBookingActions } from "./useBookingActions";
|
||||
export { useBookingActionModals } from "./useBookingActionModals";
|
||||
|
||||
// Re-export query keys for advanced use cases
|
||||
export { queryKeys } from "../config/cache.config";
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* useBookingActionModals Hook
|
||||
*
|
||||
* Centralized hook for managing all booking action modals and their API calls.
|
||||
* This hook is designed to be used across iOS, Android, and extension platforms.
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { Alert, Linking } from "react-native";
|
||||
import { CalComAPIService, type Booking } from "../services/calcom";
|
||||
import type {
|
||||
BookingRecording,
|
||||
ConferencingSession,
|
||||
AddGuestInput,
|
||||
} from "../services/types/bookings.types";
|
||||
import { showErrorAlert } from "../utils/alerts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "../config/cache.config";
|
||||
|
||||
interface UseBookingActionModalsReturn {
|
||||
// Selected booking for actions
|
||||
selectedBooking: Booking | null;
|
||||
setSelectedBooking: (booking: Booking | null) => void;
|
||||
|
||||
// Add Guests Modal
|
||||
showAddGuestsModal: boolean;
|
||||
isAddingGuests: boolean;
|
||||
openAddGuestsModal: (booking: Booking) => void;
|
||||
closeAddGuestsModal: () => void;
|
||||
handleAddGuests: (guests: AddGuestInput[]) => Promise<void>;
|
||||
|
||||
// Edit Location Modal
|
||||
showEditLocationModal: boolean;
|
||||
isUpdatingLocation: boolean;
|
||||
openEditLocationModal: (booking: Booking) => void;
|
||||
closeEditLocationModal: () => void;
|
||||
handleUpdateLocation: (location: string) => Promise<void>;
|
||||
|
||||
// View Recordings Modal
|
||||
showViewRecordingsModal: boolean;
|
||||
isLoadingRecordings: boolean;
|
||||
recordings: BookingRecording[];
|
||||
openViewRecordingsModal: (booking: Booking) => void;
|
||||
closeViewRecordingsModal: () => void;
|
||||
|
||||
// Meeting Session Details Modal
|
||||
showMeetingSessionDetailsModal: boolean;
|
||||
isLoadingSessions: boolean;
|
||||
sessions: ConferencingSession[];
|
||||
openMeetingSessionDetailsModal: (booking: Booking) => void;
|
||||
closeMeetingSessionDetailsModal: () => void;
|
||||
|
||||
// Mark No-Show Modal
|
||||
showMarkNoShowModal: boolean;
|
||||
isMarkingNoShow: boolean;
|
||||
openMarkNoShowModal: (booking: Booking) => void;
|
||||
closeMarkNoShowModal: () => void;
|
||||
handleMarkNoShow: (attendeeEmail: string, absent: boolean) => Promise<void>;
|
||||
|
||||
// Request Reschedule (deep link to web)
|
||||
handleRequestReschedule: (booking: Booking) => void;
|
||||
}
|
||||
|
||||
export function useBookingActionModals(): UseBookingActionModalsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Selected booking state
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
|
||||
// Add Guests Modal state
|
||||
const [showAddGuestsModal, setShowAddGuestsModal] = useState(false);
|
||||
const [isAddingGuests, setIsAddingGuests] = useState(false);
|
||||
|
||||
// Edit Location Modal state
|
||||
const [showEditLocationModal, setShowEditLocationModal] = useState(false);
|
||||
const [isUpdatingLocation, setIsUpdatingLocation] = useState(false);
|
||||
|
||||
// View Recordings Modal state
|
||||
const [showViewRecordingsModal, setShowViewRecordingsModal] = useState(false);
|
||||
const [isLoadingRecordings, setIsLoadingRecordings] = useState(false);
|
||||
const [recordings, setRecordings] = useState<BookingRecording[]>([]);
|
||||
|
||||
// Meeting Session Details Modal state
|
||||
const [showMeetingSessionDetailsModal, setShowMeetingSessionDetailsModal] = useState(false);
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||||
const [sessions, setSessions] = useState<ConferencingSession[]>([]);
|
||||
|
||||
// Mark No-Show Modal state
|
||||
const [showMarkNoShowModal, setShowMarkNoShowModal] = useState(false);
|
||||
const [isMarkingNoShow, setIsMarkingNoShow] = useState(false);
|
||||
|
||||
// Invalidate booking queries after mutations
|
||||
const invalidateBookingQueries = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
|
||||
if (selectedBooking?.uid) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.detail(selectedBooking.uid) });
|
||||
}
|
||||
}, [queryClient, selectedBooking?.uid]);
|
||||
|
||||
// ============================================================================
|
||||
// Add Guests
|
||||
// ============================================================================
|
||||
|
||||
const openAddGuestsModal = useCallback((booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
setShowAddGuestsModal(true);
|
||||
}, []);
|
||||
|
||||
const closeAddGuestsModal = useCallback(() => {
|
||||
setShowAddGuestsModal(false);
|
||||
}, []);
|
||||
|
||||
const handleAddGuests = useCallback(
|
||||
async (guests: AddGuestInput[]) => {
|
||||
if (!selectedBooking) {
|
||||
throw new Error("No booking selected");
|
||||
}
|
||||
|
||||
setIsAddingGuests(true);
|
||||
try {
|
||||
await CalComAPIService.addGuests(selectedBooking.uid, guests);
|
||||
Alert.alert(
|
||||
"Success",
|
||||
"Guests added successfully. They will receive an email notification."
|
||||
);
|
||||
invalidateBookingQueries();
|
||||
closeAddGuestsModal();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to add guests";
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsAddingGuests(false);
|
||||
}
|
||||
},
|
||||
[selectedBooking, invalidateBookingQueries, closeAddGuestsModal]
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Edit Location
|
||||
// ============================================================================
|
||||
|
||||
const openEditLocationModal = useCallback((booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
setShowEditLocationModal(true);
|
||||
}, []);
|
||||
|
||||
const closeEditLocationModal = useCallback(() => {
|
||||
setShowEditLocationModal(false);
|
||||
}, []);
|
||||
|
||||
const handleUpdateLocation = useCallback(
|
||||
async (location: string) => {
|
||||
if (!selectedBooking) {
|
||||
throw new Error("No booking selected");
|
||||
}
|
||||
|
||||
setIsUpdatingLocation(true);
|
||||
try {
|
||||
await CalComAPIService.updateLocation(selectedBooking.uid, location);
|
||||
Alert.alert(
|
||||
"Success",
|
||||
"Location updated successfully. Note: The calendar event may not be automatically updated."
|
||||
);
|
||||
invalidateBookingQueries();
|
||||
closeEditLocationModal();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update location";
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsUpdatingLocation(false);
|
||||
}
|
||||
},
|
||||
[selectedBooking, invalidateBookingQueries, closeEditLocationModal]
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// View Recordings
|
||||
// ============================================================================
|
||||
|
||||
const openViewRecordingsModal = useCallback(async (booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
setShowViewRecordingsModal(true);
|
||||
setIsLoadingRecordings(true);
|
||||
setRecordings([]);
|
||||
|
||||
try {
|
||||
const recordingsData = await CalComAPIService.getRecordings(booking.uid);
|
||||
setRecordings(recordingsData);
|
||||
} catch (error) {
|
||||
if (__DEV__) {
|
||||
console.debug("[useBookingActionModals] Failed to load recordings:", error);
|
||||
}
|
||||
showErrorAlert("Error", "Failed to load recordings. Please try again.");
|
||||
setShowViewRecordingsModal(false);
|
||||
} finally {
|
||||
setIsLoadingRecordings(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeViewRecordingsModal = useCallback(() => {
|
||||
setShowViewRecordingsModal(false);
|
||||
setRecordings([]);
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// Meeting Session Details
|
||||
// ============================================================================
|
||||
|
||||
const openMeetingSessionDetailsModal = useCallback(async (booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
setShowMeetingSessionDetailsModal(true);
|
||||
setIsLoadingSessions(true);
|
||||
setSessions([]);
|
||||
|
||||
try {
|
||||
const sessionsData = await CalComAPIService.getConferencingSessions(booking.uid);
|
||||
setSessions(sessionsData);
|
||||
} catch (error) {
|
||||
if (__DEV__) {
|
||||
console.debug("[useBookingActionModals] Failed to load sessions:", error);
|
||||
}
|
||||
showErrorAlert("Error", "Failed to load meeting session details. Please try again.");
|
||||
setShowMeetingSessionDetailsModal(false);
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeMeetingSessionDetailsModal = useCallback(() => {
|
||||
setShowMeetingSessionDetailsModal(false);
|
||||
setSessions([]);
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// Mark No-Show
|
||||
// ============================================================================
|
||||
|
||||
const openMarkNoShowModal = useCallback((booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
setShowMarkNoShowModal(true);
|
||||
}, []);
|
||||
|
||||
const closeMarkNoShowModal = useCallback(() => {
|
||||
setShowMarkNoShowModal(false);
|
||||
}, []);
|
||||
|
||||
const handleMarkNoShow = useCallback(
|
||||
async (attendeeEmail: string, absent: boolean) => {
|
||||
if (!selectedBooking) {
|
||||
throw new Error("No booking selected");
|
||||
}
|
||||
|
||||
setIsMarkingNoShow(true);
|
||||
try {
|
||||
await CalComAPIService.markAbsent(selectedBooking.uid, attendeeEmail, absent);
|
||||
Alert.alert("Success", absent ? "Attendee marked as no-show." : "No-show status removed.");
|
||||
invalidateBookingQueries();
|
||||
closeMarkNoShowModal();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update no-show status";
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsMarkingNoShow(false);
|
||||
}
|
||||
},
|
||||
[selectedBooking, invalidateBookingQueries, closeMarkNoShowModal]
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Request Reschedule (deep link to web)
|
||||
// ============================================================================
|
||||
|
||||
const handleRequestReschedule = useCallback((booking: Booking) => {
|
||||
// Request reschedule is a server-driven operation that requires the web app
|
||||
// We deep link to the booking detail page where the user can trigger it
|
||||
const webUrl = `https://app.cal.com/booking/${booking.uid}`;
|
||||
|
||||
Alert.alert(
|
||||
"Request Reschedule",
|
||||
"This action will open the Cal.com web app where you can request a reschedule from the attendee.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Open in Browser",
|
||||
onPress: () => {
|
||||
Linking.openURL(webUrl).catch(() => {
|
||||
showErrorAlert("Error", "Failed to open the web app. Please try again.");
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Selected booking
|
||||
selectedBooking,
|
||||
setSelectedBooking,
|
||||
|
||||
// Add Guests
|
||||
showAddGuestsModal,
|
||||
isAddingGuests,
|
||||
openAddGuestsModal,
|
||||
closeAddGuestsModal,
|
||||
handleAddGuests,
|
||||
|
||||
// Edit Location
|
||||
showEditLocationModal,
|
||||
isUpdatingLocation,
|
||||
openEditLocationModal,
|
||||
closeEditLocationModal,
|
||||
handleUpdateLocation,
|
||||
|
||||
// View Recordings
|
||||
showViewRecordingsModal,
|
||||
isLoadingRecordings,
|
||||
recordings,
|
||||
openViewRecordingsModal,
|
||||
closeViewRecordingsModal,
|
||||
|
||||
// Meeting Session Details
|
||||
showMeetingSessionDetailsModal,
|
||||
isLoadingSessions,
|
||||
sessions,
|
||||
openMeetingSessionDetailsModal,
|
||||
closeMeetingSessionDetailsModal,
|
||||
|
||||
// Mark No-Show
|
||||
showMarkNoShowModal,
|
||||
isMarkingNoShow,
|
||||
openMarkNoShowModal,
|
||||
closeMarkNoShowModal,
|
||||
handleMarkNoShow,
|
||||
|
||||
// Request Reschedule
|
||||
handleRequestReschedule,
|
||||
};
|
||||
}
|
||||
@@ -78,10 +78,32 @@ export const useBookingActions = ({
|
||||
* Open reschedule modal with pre-filled data
|
||||
*/
|
||||
const handleRescheduleBooking = (booking: Booking) => {
|
||||
// Pre-fill with the current booking date/time
|
||||
const currentDate = new Date(booking.startTime);
|
||||
const dateStr = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
const timeStr = currentDate.toTimeString().slice(0, 5); // HH:MM
|
||||
// Get the start time from booking (prefer startTime, fallback to start)
|
||||
const startTimeValue = booking.startTime || booking.start;
|
||||
|
||||
// Validate we have a valid date
|
||||
if (!startTimeValue) {
|
||||
showErrorAlert("Error", "Unable to reschedule: booking has no start time");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDate = new Date(startTimeValue);
|
||||
|
||||
// Check if the date is valid
|
||||
if (isNaN(currentDate.getTime())) {
|
||||
showErrorAlert("Error", "Unable to reschedule: invalid booking date");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use local timezone consistently (not UTC) to avoid date/time mismatch
|
||||
const year = currentDate.getFullYear();
|
||||
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(currentDate.getDate()).padStart(2, "0");
|
||||
const hours = String(currentDate.getHours()).padStart(2, "0");
|
||||
const minutes = String(currentDate.getMinutes()).padStart(2, "0");
|
||||
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
const timeStr = `${hours}:${minutes}`;
|
||||
|
||||
setRescheduleBooking(booking);
|
||||
setRescheduleDate(dateStr);
|
||||
@@ -91,7 +113,7 @@ export const useBookingActions = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate and submit reschedule request
|
||||
* Validate and submit reschedule request (uses internal state)
|
||||
*/
|
||||
const handleSubmitReschedule = () => {
|
||||
if (!rescheduleBooking || !rescheduleDate || !rescheduleTime) {
|
||||
@@ -140,6 +162,59 @@ export const useBookingActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Submit reschedule with provided values (used by RescheduleModal component)
|
||||
*/
|
||||
const handleRescheduleWithValues = async (
|
||||
date: string,
|
||||
time: string,
|
||||
reason?: string
|
||||
): Promise<void> => {
|
||||
if (!rescheduleBooking) {
|
||||
throw new Error("No booking selected");
|
||||
}
|
||||
|
||||
// Parse the date and time
|
||||
const dateTimeStr = `${date}T${time}:00`;
|
||||
const newDateTime = new Date(dateTimeStr);
|
||||
|
||||
// Validate the date
|
||||
if (isNaN(newDateTime.getTime())) {
|
||||
throw new Error(
|
||||
"Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time."
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the new time is in the future
|
||||
if (newDateTime <= new Date()) {
|
||||
throw new Error("Please select a future date and time");
|
||||
}
|
||||
|
||||
// Convert to UTC ISO string
|
||||
const startUtc = newDateTime.toISOString();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
rescheduleMutation(
|
||||
{
|
||||
uid: rescheduleBooking.uid,
|
||||
start: startUtc,
|
||||
reschedulingReason: reason || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowRescheduleModal(false);
|
||||
setRescheduleBooking(null);
|
||||
Alert.alert("Success", "Booking rescheduled successfully");
|
||||
resolve();
|
||||
},
|
||||
onError: (error) => {
|
||||
reject(new Error(error.message || "Failed to reschedule booking"));
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Close reschedule modal and reset state
|
||||
*/
|
||||
@@ -344,6 +419,7 @@ export const useBookingActions = ({
|
||||
handleBookingPress,
|
||||
handleRescheduleBooking,
|
||||
handleSubmitReschedule,
|
||||
handleRescheduleWithValues,
|
||||
handleCloseRescheduleModal,
|
||||
handleCancelBooking,
|
||||
handleConfirmBooking,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* useBookingActionsGating Hook
|
||||
*
|
||||
* This hook provides the booking actions state using the centralized gating utility.
|
||||
* It computes which actions are visible and enabled for a given booking.
|
||||
*/
|
||||
import type { Booking } from "../services/calcom";
|
||||
import type { EventType } from "../services/types/event-types.types";
|
||||
import {
|
||||
normalizeBooking,
|
||||
getBookingActions,
|
||||
type BookingActionsResult,
|
||||
type NormalizedBooking,
|
||||
} from "../utils/booking-actions";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface UseBookingActionsGatingParams {
|
||||
booking: Booking | null;
|
||||
eventType?: EventType | null;
|
||||
currentUserId?: number;
|
||||
currentUserEmail?: string;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
interface UseBookingActionsGatingResult {
|
||||
actions: BookingActionsResult;
|
||||
normalizedBooking: NormalizedBooking | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default actions result when no booking is provided
|
||||
*/
|
||||
const defaultActions: 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 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to compute booking actions visibility and enabled state.
|
||||
*
|
||||
* @param params - Parameters including booking, eventType, and user info
|
||||
* @returns Actions result and normalized booking
|
||||
*/
|
||||
export function useBookingActionsGating({
|
||||
booking,
|
||||
eventType,
|
||||
currentUserId,
|
||||
currentUserEmail,
|
||||
isOnline = true,
|
||||
}: UseBookingActionsGatingParams): UseBookingActionsGatingResult {
|
||||
// Normalize the booking and compute actions
|
||||
const result = useMemo(() => {
|
||||
if (!booking) {
|
||||
return {
|
||||
actions: defaultActions,
|
||||
normalizedBooking: null,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedBooking = normalizeBooking(booking);
|
||||
const actions = getBookingActions({
|
||||
booking: normalizedBooking,
|
||||
eventType,
|
||||
currentUserId,
|
||||
currentUserEmail,
|
||||
isOnline,
|
||||
});
|
||||
|
||||
return {
|
||||
actions,
|
||||
normalizedBooking,
|
||||
};
|
||||
}, [booking, eventType, currentUserId, currentUserEmail, isOnline]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper hook to get simple boolean flags for backward compatibility.
|
||||
* This can be used by components that still need the old-style flags.
|
||||
*/
|
||||
export function useBookingStatusFlags(booking: Booking | null) {
|
||||
return useMemo(() => {
|
||||
if (!booking) {
|
||||
return {
|
||||
isUpcoming: false,
|
||||
isPast: false,
|
||||
isOngoing: false,
|
||||
isCancelled: false,
|
||||
isRejected: false,
|
||||
isPending: false,
|
||||
isConfirmed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizeBooking(booking);
|
||||
const now = new Date();
|
||||
|
||||
const isPast = normalized.endTime < now;
|
||||
const isUpcoming = normalized.endTime >= now;
|
||||
const isOngoing = isUpcoming && now >= normalized.startTime;
|
||||
|
||||
return {
|
||||
isUpcoming,
|
||||
isPast,
|
||||
isOngoing,
|
||||
isCancelled: normalized.status === "CANCELLED",
|
||||
isRejected: normalized.status === "REJECTED",
|
||||
isPending: normalized.status === "PENDING",
|
||||
isConfirmed: normalized.status === "ACCEPTED",
|
||||
};
|
||||
}, [booking]);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* useNetworkStatus Hook
|
||||
*
|
||||
* This hook provides real-time network status for components.
|
||||
* Used to disable mutations when offline.
|
||||
*/
|
||||
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface NetworkStatus {
|
||||
isOnline: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to monitor network connectivity status.
|
||||
* Returns whether the device is online and if the status is still loading.
|
||||
*/
|
||||
export function useNetworkStatus(): NetworkStatus {
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial state
|
||||
NetInfo.fetch().then((state: NetInfoState) => {
|
||||
setIsOnline(state.isConnected === true && state.isInternetReachable !== false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Subscribe to network state changes
|
||||
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
|
||||
setIsOnline(state.isConnected === true && state.isInternetReachable !== false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isOnline, isLoading };
|
||||
}
|
||||
@@ -18,6 +18,15 @@ import type {
|
||||
UpdatePrivateLinkInput,
|
||||
MarkAbsentRequest,
|
||||
MarkAbsentResponse,
|
||||
AddGuestInput,
|
||||
AddGuestsResponse,
|
||||
UpdateLocationResponse,
|
||||
BookingRecording,
|
||||
GetRecordingsResponse,
|
||||
ConferencingSession,
|
||||
GetConferencingSessionsResponse,
|
||||
BookingTranscript,
|
||||
GetTranscriptsResponse,
|
||||
} from "./types";
|
||||
|
||||
const API_BASE_URL = "https://api.cal.com/v2";
|
||||
@@ -305,7 +314,8 @@ export class CalComAPIService {
|
||||
throw new Error("Authentication failed. Please check your credentials.");
|
||||
}
|
||||
|
||||
throw new Error(`API Error: ${errorMessage}`);
|
||||
// Include status code in error message for graceful error handling downstream
|
||||
throw new Error(`API Error: ${response.status} ${errorMessage}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -387,6 +397,11 @@ export class CalComAPIService {
|
||||
}
|
||||
): Promise<Booking> {
|
||||
try {
|
||||
console.log("[CalComAPIService] rescheduleBooking request:", {
|
||||
bookingUid,
|
||||
input,
|
||||
});
|
||||
|
||||
const response = await this.makeRequest<{ status: string; data: Booking }>(
|
||||
`/bookings/${bookingUid}/reschedule`,
|
||||
{
|
||||
@@ -406,7 +421,11 @@ export class CalComAPIService {
|
||||
|
||||
throw new Error("Invalid response from reschedule booking API");
|
||||
} catch (error) {
|
||||
console.error("rescheduleBooking error");
|
||||
console.error("[CalComAPIService] rescheduleBooking error:", error);
|
||||
if (error instanceof Error) {
|
||||
console.error("[CalComAPIService] Error message:", error.message);
|
||||
console.error("[CalComAPIService] Error stack:", error.stack);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -507,6 +526,236 @@ export class CalComAPIService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Booking Action API Methods (API v2 2024-08-13)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add guests to a booking
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
* @param guests - Array of guests to add (email required, name optional)
|
||||
* @returns Updated booking with new guests
|
||||
*/
|
||||
static async addGuests(bookingUid: string, guests: AddGuestInput[]): Promise<Booking> {
|
||||
try {
|
||||
const response = await this.makeRequest<AddGuestsResponse>(
|
||||
`/bookings/${bookingUid}/guests`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"cal-api-version": "2024-08-13",
|
||||
},
|
||||
body: JSON.stringify({ guests }),
|
||||
},
|
||||
"2024-08-13"
|
||||
);
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error("Invalid response from add guests API");
|
||||
} catch (error) {
|
||||
console.error("addGuests error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the location of a booking (legacy - sends location as string)
|
||||
* Note: This updates the booking location but may not update the calendar event
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
* @param location - The new location string
|
||||
* @returns Updated booking with new location
|
||||
* @deprecated Use updateLocationV2 instead for proper typed location updates
|
||||
*/
|
||||
static async updateLocation(bookingUid: string, location: string): Promise<Booking> {
|
||||
try {
|
||||
const response = await this.makeRequest<UpdateLocationResponse>(
|
||||
`/bookings/${bookingUid}/location`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"cal-api-version": "2024-08-13",
|
||||
},
|
||||
body: JSON.stringify({ location }),
|
||||
},
|
||||
"2024-08-13"
|
||||
);
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error("Invalid response from update location API");
|
||||
} catch (error) {
|
||||
console.error("updateLocation error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the location of a booking with typed location object
|
||||
* Supports: address, link, phone, attendeePhone, attendeeAddress, attendeeDefined
|
||||
* Note: Integration-based locations (Cal Video, Google Meet, Zoom) are NOT supported
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
* @param location - The location object with type and value
|
||||
* @returns Updated booking with new location
|
||||
*/
|
||||
static async updateLocationV2(
|
||||
bookingUid: string,
|
||||
location: { type: string; [key: string]: string }
|
||||
): Promise<Booking> {
|
||||
try {
|
||||
const response = await this.makeRequest<UpdateLocationResponse>(
|
||||
`/bookings/${bookingUid}/location`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"cal-api-version": "2024-08-13",
|
||||
},
|
||||
body: JSON.stringify({ location }),
|
||||
},
|
||||
"2024-08-13"
|
||||
);
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error("Invalid response from update location API");
|
||||
} catch (error) {
|
||||
console.error("[CalComAPIService] updateLocationV2 error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recordings for a booking (Cal Video only)
|
||||
* Note: Only available for past Cal Video bookings
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
* @returns Array of recording objects with download URLs
|
||||
*/
|
||||
static async getRecordings(bookingUid: string): Promise<BookingRecording[]> {
|
||||
try {
|
||||
const response = await this.makeRequest<GetRecordingsResponse>(
|
||||
`/bookings/${bookingUid}/recordings`,
|
||||
{
|
||||
headers: {
|
||||
"cal-api-version": "2024-08-13",
|
||||
},
|
||||
},
|
||||
"2024-08-13"
|
||||
);
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
// Handle 401/403 gracefully - endpoint may require auth in future
|
||||
if (error instanceof Error) {
|
||||
const statusMatch = error.message.match(/API Error: (\d+)/);
|
||||
if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) {
|
||||
console.warn(`Recordings access denied for booking ${bookingUid}`);
|
||||
return [];
|
||||
}
|
||||
if (statusMatch && statusMatch[1] === "404") {
|
||||
console.warn(`No recordings found for booking ${bookingUid}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
console.error("getRecordings error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conferencing session details for a booking (Cal Video only)
|
||||
* Note: This endpoint is PBAC-guarded and requires booking.readRecordings permission
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
* @returns Array of conferencing session objects with participant info
|
||||
*/
|
||||
static async getConferencingSessions(bookingUid: string): Promise<ConferencingSession[]> {
|
||||
try {
|
||||
const response = await this.makeRequest<GetConferencingSessionsResponse>(
|
||||
`/bookings/${bookingUid}/conferencing-sessions`,
|
||||
{
|
||||
headers: {
|
||||
"cal-api-version": "2024-08-13",
|
||||
},
|
||||
},
|
||||
"2024-08-13"
|
||||
);
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
// Handle 401/403 gracefully - endpoint is PBAC-guarded
|
||||
if (error instanceof Error) {
|
||||
const statusMatch = error.message.match(/API Error: (\d+)/);
|
||||
if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) {
|
||||
console.warn(`Conferencing sessions access denied for booking ${bookingUid}`);
|
||||
return [];
|
||||
}
|
||||
if (statusMatch && statusMatch[1] === "404") {
|
||||
console.warn(`No conferencing sessions found for booking ${bookingUid}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
console.error("getConferencingSessions error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transcripts for a booking (Cal Video only)
|
||||
* Note: This endpoint may require authentication in future releases
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
* @returns Array of transcript objects with download URLs
|
||||
*/
|
||||
static async getTranscripts(bookingUid: string): Promise<BookingTranscript[]> {
|
||||
try {
|
||||
const response = await this.makeRequest<GetTranscriptsResponse>(
|
||||
`/bookings/${bookingUid}/transcripts`,
|
||||
{
|
||||
headers: {
|
||||
"cal-api-version": "2024-08-13",
|
||||
},
|
||||
},
|
||||
"2024-08-13"
|
||||
);
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
// Handle 401/403 gracefully - endpoint may require auth in future
|
||||
if (error instanceof Error) {
|
||||
const statusMatch = error.message.match(/API Error: (\d+)/);
|
||||
if (statusMatch && (statusMatch[1] === "401" || statusMatch[1] === "403")) {
|
||||
console.warn(`Transcripts access denied for booking ${bookingUid}`);
|
||||
return [];
|
||||
}
|
||||
if (statusMatch && statusMatch[1] === "404") {
|
||||
console.warn(`No transcripts found for booking ${bookingUid}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
console.error("getTranscripts error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getEventTypes(): Promise<EventType[]> {
|
||||
try {
|
||||
// Get current user to extract username
|
||||
|
||||
@@ -77,3 +77,97 @@ export interface MarkAbsentResponse {
|
||||
status: "success" | "error";
|
||||
data: Booking;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add Guests Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AddGuestInput {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AddGuestsRequest {
|
||||
guests: AddGuestInput[];
|
||||
}
|
||||
|
||||
export interface AddGuestsResponse {
|
||||
status: "success" | "error";
|
||||
data: Booking;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Update Location Types
|
||||
// ============================================================================
|
||||
|
||||
export interface UpdateLocationRequest {
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface UpdateLocationResponse {
|
||||
status: "success" | "error";
|
||||
data: Booking;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Recordings Types
|
||||
// ============================================================================
|
||||
|
||||
export interface BookingRecording {
|
||||
id: string;
|
||||
roomName: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
downloadUrl: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface GetRecordingsResponse {
|
||||
status: "success" | "error";
|
||||
data: BookingRecording[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conferencing Sessions Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ConferencingSessionParticipant {
|
||||
id: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
joinTime: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ConferencingSession {
|
||||
id: string;
|
||||
roomName: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
maxParticipants?: number;
|
||||
participants?: ConferencingSessionParticipant[];
|
||||
}
|
||||
|
||||
export interface GetConferencingSessionsResponse {
|
||||
status: "success" | "error";
|
||||
data: ConferencingSession[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transcripts Types
|
||||
// ============================================================================
|
||||
|
||||
export interface BookingTranscript {
|
||||
id: string;
|
||||
roomName: string;
|
||||
startTime: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export interface GetTranscriptsResponse {
|
||||
status: "success" | "error";
|
||||
data: BookingTranscript[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@@ -218,7 +218,13 @@ export interface EventType {
|
||||
}>;
|
||||
|
||||
// Metadata
|
||||
metadata?: Record<string, any>;
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
// Booking action settings (may also be in metadata)
|
||||
disableRescheduling?: boolean;
|
||||
disableCancelling?: boolean;
|
||||
minimumRescheduleNotice?: number;
|
||||
allowReschedulingPastBookings?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateEventTypeInput {
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"types": ["nativewind/types"]
|
||||
},
|
||||
"extends": "expo/tsconfig.base",
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* Centralized Booking Action Gating Utility
|
||||
*
|
||||
* This module provides a single source of truth for booking action visibility and enabled state.
|
||||
* It matches the web's semantics from apps/web/components/booking/actions/bookingActions.ts
|
||||
*
|
||||
* Parity Scope (In Scope Now):
|
||||
* - cancel, reschedule, reschedule_request, change_location, add_guests
|
||||
* - view_recordings, meeting_session_details, mark_no_show
|
||||
*
|
||||
* Deferred:
|
||||
* - report, reroute, reassign, charge_card
|
||||
*/
|
||||
import type { Booking } from "../services/types/bookings.types";
|
||||
import type { EventType } from "../services/types/event-types.types";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface BookingActionResult {
|
||||
visible: boolean;
|
||||
enabled: boolean;
|
||||
disabledReason?: DisabledReason;
|
||||
needsData?: string[];
|
||||
}
|
||||
|
||||
export type DisabledReason =
|
||||
| "BOOKING_IN_PAST"
|
||||
| "BOOKING_CANCELLED"
|
||||
| "BOOKING_REJECTED"
|
||||
| "BOOKING_PENDING"
|
||||
| "WITHIN_MINIMUM_NOTICE"
|
||||
| "RESCHEDULING_DISABLED"
|
||||
| "CANCELLING_DISABLED"
|
||||
| "GUESTS_DISABLED"
|
||||
| "NOT_CAL_VIDEO"
|
||||
| "NO_RECORDINGS"
|
||||
| "NO_ATTENDEE_DATA"
|
||||
| "SEATED_BOOKING"
|
||||
| "NOT_ORGANIZER"
|
||||
| "OFFLINE";
|
||||
|
||||
export interface BookingActionsResult {
|
||||
reschedule: BookingActionResult;
|
||||
rescheduleRequest: BookingActionResult;
|
||||
cancel: BookingActionResult;
|
||||
changeLocation: BookingActionResult;
|
||||
addGuests: BookingActionResult;
|
||||
viewRecordings: BookingActionResult;
|
||||
meetingSessionDetails: BookingActionResult;
|
||||
markNoShow: BookingActionResult;
|
||||
}
|
||||
|
||||
export interface BookingActionContext {
|
||||
booking: Booking | NormalizedBooking;
|
||||
eventType?: EventType | null;
|
||||
currentUserId?: number;
|
||||
currentUserEmail?: string;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedBooking {
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
status: BookingStatus;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
location?: string;
|
||||
isRecorded?: boolean;
|
||||
rescheduled?: boolean;
|
||||
fromReschedule?: string;
|
||||
recurringEventId?: string;
|
||||
eventTypeId?: number;
|
||||
user?: {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
hosts?: Array<{
|
||||
id?: number | string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
attendees?: Array<{
|
||||
id?: number | string;
|
||||
email: string;
|
||||
name: string;
|
||||
noShow?: boolean;
|
||||
}>;
|
||||
seatsReferences?: Array<{
|
||||
referenceUid: string;
|
||||
attendee?: {
|
||||
email: string;
|
||||
};
|
||||
}>;
|
||||
payment?: Array<{
|
||||
id: number;
|
||||
success: boolean;
|
||||
paymentOption: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type BookingStatus = "ACCEPTED" | "PENDING" | "CANCELLED" | "REJECTED";
|
||||
|
||||
// ============================================================================
|
||||
// Normalization Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Normalizes a booking object from API response to a consistent format.
|
||||
* - Normalizes status to uppercase
|
||||
* - Converts time strings to Date objects
|
||||
* - Ensures consistent field names
|
||||
*/
|
||||
export function normalizeBooking(booking: Booking): NormalizedBooking {
|
||||
// Normalize status to uppercase (API v2 may return lowercase)
|
||||
const status = (booking.status?.toUpperCase() || "PENDING") as BookingStatus;
|
||||
|
||||
// Normalize times - prefer startTime/endTime, fallback to start/end
|
||||
const startTimeStr = booking.startTime || booking.start || "";
|
||||
const endTimeStr = booking.endTime || booking.end || "";
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
uid: booking.uid,
|
||||
title: booking.title,
|
||||
status,
|
||||
startTime: new Date(startTimeStr),
|
||||
endTime: new Date(endTimeStr),
|
||||
location: booking.location,
|
||||
isRecorded: (booking as any).isRecorded,
|
||||
rescheduled: booking.rescheduled,
|
||||
fromReschedule: booking.fromReschedule,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
user: booking.user,
|
||||
hosts: booking.hosts,
|
||||
attendees: booking.attendees,
|
||||
seatsReferences: (booking as any).seatsReferences,
|
||||
payment: booking.payment,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Time Computation Functions (matching web's exact definitions)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if booking is in the past.
|
||||
* Web definition: new Date(booking.endTime) < new Date()
|
||||
*/
|
||||
export function isBookingInPast(booking: NormalizedBooking, now: Date = new Date()): boolean {
|
||||
return booking.endTime < now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if booking is upcoming.
|
||||
* Web definition: new Date(booking.endTime) >= new Date()
|
||||
*/
|
||||
export function isBookingUpcoming(booking: NormalizedBooking, now: Date = new Date()): boolean {
|
||||
return booking.endTime >= now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if booking is currently ongoing.
|
||||
* Web definition: isUpcoming && new Date() >= new Date(booking.startTime)
|
||||
*/
|
||||
export function isBookingOngoing(booking: NormalizedBooking, now: Date = new Date()): boolean {
|
||||
return isBookingUpcoming(booking, now) && now >= booking.startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if booking is cancelled.
|
||||
*/
|
||||
export function isBookingCancelled(booking: NormalizedBooking): boolean {
|
||||
return booking.status === "CANCELLED";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if booking is rejected.
|
||||
*/
|
||||
export function isBookingRejected(booking: NormalizedBooking): boolean {
|
||||
return booking.status === "REJECTED";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if booking is pending (unconfirmed).
|
||||
*/
|
||||
export function isBookingPending(booking: NormalizedBooking): boolean {
|
||||
return booking.status === "PENDING";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if booking is confirmed (accepted).
|
||||
*/
|
||||
export function isBookingConfirmed(booking: NormalizedBooking): boolean {
|
||||
return booking.status === "ACCEPTED";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Role Detection Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if the current user is the organizer of the booking.
|
||||
*/
|
||||
export function isUserOrganizer(
|
||||
booking: NormalizedBooking,
|
||||
userId?: number,
|
||||
userEmail?: string
|
||||
): boolean {
|
||||
if (!booking.user) return false;
|
||||
|
||||
if (userId && booking.user.id === userId) return true;
|
||||
if (userEmail && booking.user.email?.toLowerCase() === userEmail.toLowerCase()) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a host of the booking.
|
||||
*/
|
||||
export function isUserHost(
|
||||
booking: NormalizedBooking,
|
||||
userId?: number,
|
||||
userEmail?: string
|
||||
): boolean {
|
||||
if (!booking.hosts || booking.hosts.length === 0) return false;
|
||||
|
||||
return booking.hosts.some((host) => {
|
||||
if (userId && host.id !== undefined && String(host.id) === String(userId)) return true;
|
||||
if (userEmail && host.email?.toLowerCase() === userEmail.toLowerCase()) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is an attendee of the booking.
|
||||
*/
|
||||
export function isUserAttendee(
|
||||
booking: NormalizedBooking,
|
||||
userId?: number,
|
||||
userEmail?: string
|
||||
): boolean {
|
||||
if (!booking.attendees || booking.attendees.length === 0) return false;
|
||||
|
||||
return booking.attendees.some((attendee) => {
|
||||
if (userId && attendee.id !== undefined && String(attendee.id) === String(userId)) return true;
|
||||
if (userEmail && attendee.email?.toLowerCase() === userEmail.toLowerCase()) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's seat reference UID if they are an attendee of a seated booking.
|
||||
*/
|
||||
export function getUserSeatReferenceUid(
|
||||
booking: NormalizedBooking,
|
||||
userEmail?: string
|
||||
): string | undefined {
|
||||
if (!booking.seatsReferences || !userEmail) return undefined;
|
||||
|
||||
const userSeat = booking.seatsReferences.find(
|
||||
(seat) => seat.attendee?.email?.toLowerCase() === userEmail.toLowerCase()
|
||||
);
|
||||
|
||||
return userSeat?.referenceUid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if booking is a seated booking.
|
||||
*/
|
||||
export function isSeatedBooking(booking: NormalizedBooking): boolean {
|
||||
return Boolean(booking.seatsReferences && booking.seatsReferences.length > 0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Location Detection Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if the booking location is Cal Video.
|
||||
* Web definition: !booking.location || booking.location === "integrations:daily" || location.trim() === ""
|
||||
*/
|
||||
export function isCalVideoLocation(booking: NormalizedBooking): boolean {
|
||||
const location = booking.location;
|
||||
|
||||
if (!location) return true;
|
||||
if (location === "integrations:daily") return true;
|
||||
if (typeof location === "string" && location.trim() === "") return true;
|
||||
if (location.includes("cal.com/video") || location.includes("cal-video")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Minimum Reschedule Notice Check
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if the booking is within the minimum reschedule notice period.
|
||||
* This should only apply to non-organizers (attendees).
|
||||
*/
|
||||
export function isWithinMinimumRescheduleNotice(
|
||||
bookingStartTime: Date,
|
||||
minimumRescheduleNotice: number | null | undefined,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
if (!minimumRescheduleNotice || minimumRescheduleNotice <= 0) return false;
|
||||
|
||||
const noticeEndTime = new Date(bookingStartTime.getTime() - minimumRescheduleNotice * 60 * 1000);
|
||||
return now >= noticeEndTime;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Action Gating Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Helper to check if a booking is already normalized (has Date objects for times)
|
||||
*/
|
||||
function isNormalizedBooking(booking: Booking | NormalizedBooking): booking is NormalizedBooking {
|
||||
return booking.startTime instanceof Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visibility and enabled state for all booking actions.
|
||||
* This is the main entry point for action gating.
|
||||
* Accepts either a raw Booking or NormalizedBooking and normalizes internally.
|
||||
*/
|
||||
export function getBookingActions(context: BookingActionContext): BookingActionsResult {
|
||||
const {
|
||||
booking: rawBooking,
|
||||
eventType,
|
||||
currentUserId,
|
||||
currentUserEmail,
|
||||
isOnline = true,
|
||||
} = context;
|
||||
const now = new Date();
|
||||
|
||||
// Normalize booking if needed (convert string times to Date objects)
|
||||
const booking: NormalizedBooking = isNormalizedBooking(rawBooking)
|
||||
? rawBooking
|
||||
: normalizeBooking(rawBooking);
|
||||
|
||||
// Compute booking state flags
|
||||
const isPast = isBookingInPast(booking, now);
|
||||
const isOngoing = isBookingOngoing(booking, now);
|
||||
const isCancelled = isBookingCancelled(booking);
|
||||
const isRejected = isBookingRejected(booking);
|
||||
const isPending = isBookingPending(booking);
|
||||
const isConfirmed = isBookingConfirmed(booking);
|
||||
const isCalVideo = isCalVideoLocation(booking);
|
||||
const isSeated = isSeatedBooking(booking);
|
||||
|
||||
// Compute user role flags
|
||||
const isOrganizer = isUserOrganizer(booking, currentUserId, currentUserEmail);
|
||||
const isHost = isUserHost(booking, currentUserId, currentUserEmail);
|
||||
const isOrganizerOrHost = isOrganizer || isHost;
|
||||
|
||||
// Get event type settings (with defaults)
|
||||
const disableRescheduling = eventType?.disableRescheduling ?? false;
|
||||
const disableCancelling = eventType?.disableCancelling ?? false;
|
||||
const disableGuests = eventType?.disableGuests ?? false;
|
||||
const minimumRescheduleNotice = eventType?.minimumRescheduleNotice ?? null;
|
||||
const allowReschedulingPastBookings = eventType?.allowReschedulingPastBookings ?? false;
|
||||
|
||||
// Check minimum reschedule notice (only applies to non-organizers)
|
||||
const withinMinimumNotice =
|
||||
!isOrganizerOrHost &&
|
||||
isWithinMinimumRescheduleNotice(booking.startTime, minimumRescheduleNotice, now);
|
||||
|
||||
// ============================================================================
|
||||
// Reschedule Action
|
||||
// Visible for upcoming, non-cancelled, non-pending bookings
|
||||
// ============================================================================
|
||||
const isUpcoming = !isPast;
|
||||
const canReschedule =
|
||||
(isUpcoming || allowReschedulingPastBookings) &&
|
||||
!isCancelled &&
|
||||
!isRejected &&
|
||||
!isPending &&
|
||||
!disableRescheduling;
|
||||
const reschedule: BookingActionResult = {
|
||||
visible: isUpcoming || allowReschedulingPastBookings,
|
||||
enabled: canReschedule,
|
||||
needsData: eventType ? undefined : ["eventType"],
|
||||
};
|
||||
|
||||
if (!isOnline) {
|
||||
reschedule.enabled = false;
|
||||
reschedule.disabledReason = "OFFLINE";
|
||||
} else if (isCancelled) {
|
||||
reschedule.visible = false;
|
||||
reschedule.enabled = false;
|
||||
reschedule.disabledReason = "BOOKING_CANCELLED";
|
||||
} else if (isRejected) {
|
||||
reschedule.visible = false;
|
||||
reschedule.enabled = false;
|
||||
reschedule.disabledReason = "BOOKING_REJECTED";
|
||||
} else if (isPending) {
|
||||
reschedule.visible = false;
|
||||
reschedule.enabled = false;
|
||||
reschedule.disabledReason = "BOOKING_PENDING";
|
||||
} else if (isPast && !allowReschedulingPastBookings) {
|
||||
reschedule.visible = false;
|
||||
reschedule.enabled = false;
|
||||
reschedule.disabledReason = "BOOKING_IN_PAST";
|
||||
} else if (disableRescheduling) {
|
||||
reschedule.enabled = false;
|
||||
reschedule.disabledReason = "RESCHEDULING_DISABLED";
|
||||
} else if (withinMinimumNotice) {
|
||||
reschedule.enabled = false;
|
||||
reschedule.disabledReason = "WITHIN_MINIMUM_NOTICE";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reschedule Request Action (for organizers to request attendee to reschedule)
|
||||
// Visible only for organizers/hosts with upcoming, non-cancelled, non-pending bookings
|
||||
// ============================================================================
|
||||
const canRequestReschedule =
|
||||
isOrganizerOrHost &&
|
||||
(isUpcoming || allowReschedulingPastBookings) &&
|
||||
!isCancelled &&
|
||||
!isRejected &&
|
||||
!isPending &&
|
||||
!disableRescheduling &&
|
||||
!isSeated;
|
||||
const rescheduleRequest: BookingActionResult = {
|
||||
visible: isOrganizerOrHost && (isUpcoming || allowReschedulingPastBookings),
|
||||
enabled: canRequestReschedule,
|
||||
needsData: eventType ? undefined : ["eventType"],
|
||||
};
|
||||
|
||||
if (!isOrganizerOrHost) {
|
||||
rescheduleRequest.visible = false;
|
||||
rescheduleRequest.enabled = false;
|
||||
rescheduleRequest.disabledReason = "NOT_ORGANIZER";
|
||||
} else if (!isOnline) {
|
||||
rescheduleRequest.enabled = false;
|
||||
rescheduleRequest.disabledReason = "OFFLINE";
|
||||
} else if (isCancelled) {
|
||||
rescheduleRequest.visible = false;
|
||||
rescheduleRequest.enabled = false;
|
||||
rescheduleRequest.disabledReason = "BOOKING_CANCELLED";
|
||||
} else if (isRejected) {
|
||||
rescheduleRequest.visible = false;
|
||||
rescheduleRequest.enabled = false;
|
||||
rescheduleRequest.disabledReason = "BOOKING_REJECTED";
|
||||
} else if (isPending) {
|
||||
rescheduleRequest.visible = false;
|
||||
rescheduleRequest.enabled = false;
|
||||
rescheduleRequest.disabledReason = "BOOKING_PENDING";
|
||||
} else if (isPast && !allowReschedulingPastBookings) {
|
||||
rescheduleRequest.visible = false;
|
||||
rescheduleRequest.enabled = false;
|
||||
rescheduleRequest.disabledReason = "BOOKING_IN_PAST";
|
||||
} else if (disableRescheduling) {
|
||||
rescheduleRequest.enabled = false;
|
||||
rescheduleRequest.disabledReason = "RESCHEDULING_DISABLED";
|
||||
} else if (isSeated) {
|
||||
// Web disables reschedule_request for seated bookings
|
||||
rescheduleRequest.enabled = false;
|
||||
rescheduleRequest.disabledReason = "SEATED_BOOKING";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cancel Action
|
||||
// Visible for upcoming, non-cancelled bookings
|
||||
// ============================================================================
|
||||
const canCancel = isUpcoming && !isCancelled && !isRejected && !disableCancelling;
|
||||
const cancel: BookingActionResult = {
|
||||
visible: canCancel,
|
||||
enabled: canCancel,
|
||||
};
|
||||
|
||||
if (!canCancel) {
|
||||
if (isCancelled) {
|
||||
cancel.visible = false;
|
||||
cancel.enabled = false;
|
||||
cancel.disabledReason = "BOOKING_CANCELLED";
|
||||
} else if (isRejected) {
|
||||
cancel.visible = false;
|
||||
cancel.enabled = false;
|
||||
cancel.disabledReason = "BOOKING_REJECTED";
|
||||
} else if (isPast) {
|
||||
cancel.visible = false;
|
||||
cancel.enabled = false;
|
||||
cancel.disabledReason = "BOOKING_IN_PAST";
|
||||
} else if (disableCancelling) {
|
||||
cancel.enabled = false;
|
||||
cancel.disabledReason = "CANCELLING_DISABLED";
|
||||
}
|
||||
} else if (!isOnline) {
|
||||
cancel.enabled = false;
|
||||
cancel.disabledReason = "OFFLINE";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Change Location Action
|
||||
// Visible for upcoming, non-cancelled, non-pending bookings
|
||||
// ============================================================================
|
||||
const canChangeLocation = isUpcoming && !isCancelled && !isRejected && !isPending;
|
||||
const changeLocation: BookingActionResult = {
|
||||
visible: canChangeLocation,
|
||||
enabled: canChangeLocation,
|
||||
};
|
||||
|
||||
if (!canChangeLocation) {
|
||||
changeLocation.visible = false;
|
||||
changeLocation.enabled = false;
|
||||
if (isCancelled) {
|
||||
changeLocation.disabledReason = "BOOKING_CANCELLED";
|
||||
} else if (isRejected) {
|
||||
changeLocation.disabledReason = "BOOKING_REJECTED";
|
||||
} else if (isPending) {
|
||||
changeLocation.disabledReason = "BOOKING_PENDING";
|
||||
} else if (isPast) {
|
||||
changeLocation.disabledReason = "BOOKING_IN_PAST";
|
||||
}
|
||||
} else if (!isOnline) {
|
||||
changeLocation.enabled = false;
|
||||
changeLocation.disabledReason = "OFFLINE";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add Guests Action
|
||||
// Visible for upcoming, non-cancelled, non-pending bookings (if guests enabled)
|
||||
// ============================================================================
|
||||
const canAddGuests = isUpcoming && !isCancelled && !isRejected && !isPending && !disableGuests;
|
||||
const addGuests: BookingActionResult = {
|
||||
visible: canAddGuests,
|
||||
enabled: canAddGuests,
|
||||
needsData: eventType ? undefined : ["eventType"],
|
||||
};
|
||||
|
||||
if (disableGuests) {
|
||||
addGuests.visible = false;
|
||||
addGuests.enabled = false;
|
||||
addGuests.disabledReason = "GUESTS_DISABLED";
|
||||
} else if (!canAddGuests) {
|
||||
addGuests.visible = false;
|
||||
addGuests.enabled = false;
|
||||
if (isCancelled) {
|
||||
addGuests.disabledReason = "BOOKING_CANCELLED";
|
||||
} else if (isRejected) {
|
||||
addGuests.disabledReason = "BOOKING_REJECTED";
|
||||
} else if (isPending) {
|
||||
addGuests.disabledReason = "BOOKING_PENDING";
|
||||
} else if (isPast) {
|
||||
addGuests.disabledReason = "BOOKING_IN_PAST";
|
||||
}
|
||||
} else if (!isOnline) {
|
||||
addGuests.enabled = false;
|
||||
addGuests.disabledReason = "OFFLINE";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// View Recordings Action
|
||||
// Only visible for past Cal Video bookings
|
||||
// Note: We show this for all past Cal Video bookings, not just those with isRecorded=true,
|
||||
// because the isRecorded field may not always be reliable. The view-recordings screen
|
||||
// will handle showing an empty state if there are no recordings.
|
||||
// ============================================================================
|
||||
const canViewRecordings = isCalVideo && isPast && isConfirmed && !isCancelled && !isRejected;
|
||||
const viewRecordings: BookingActionResult = {
|
||||
visible: canViewRecordings,
|
||||
enabled: canViewRecordings,
|
||||
};
|
||||
|
||||
if (!isCalVideo) {
|
||||
viewRecordings.visible = false;
|
||||
viewRecordings.enabled = false;
|
||||
viewRecordings.disabledReason = "NOT_CAL_VIDEO";
|
||||
} else if (!isPast) {
|
||||
viewRecordings.visible = false;
|
||||
viewRecordings.enabled = false;
|
||||
viewRecordings.disabledReason = "BOOKING_IN_PAST";
|
||||
} else if (!isConfirmed || isCancelled || isRejected) {
|
||||
viewRecordings.visible = false;
|
||||
viewRecordings.enabled = false;
|
||||
viewRecordings.disabledReason = isCancelled
|
||||
? "BOOKING_CANCELLED"
|
||||
: isRejected
|
||||
? "BOOKING_REJECTED"
|
||||
: "BOOKING_PENDING";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Meeting Session Details Action
|
||||
// Only visible for past Cal Video bookings
|
||||
// ============================================================================
|
||||
const canViewSessionDetails = isCalVideo && isPast && isConfirmed && !isCancelled && !isRejected;
|
||||
const meetingSessionDetails: BookingActionResult = {
|
||||
visible: canViewSessionDetails,
|
||||
enabled: canViewSessionDetails,
|
||||
};
|
||||
|
||||
if (!isCalVideo) {
|
||||
meetingSessionDetails.visible = false;
|
||||
meetingSessionDetails.enabled = false;
|
||||
meetingSessionDetails.disabledReason = "NOT_CAL_VIDEO";
|
||||
} else if (!isPast) {
|
||||
meetingSessionDetails.visible = false;
|
||||
meetingSessionDetails.enabled = false;
|
||||
meetingSessionDetails.disabledReason = "BOOKING_IN_PAST";
|
||||
} else if (!isConfirmed || isCancelled || isRejected) {
|
||||
meetingSessionDetails.visible = false;
|
||||
meetingSessionDetails.enabled = false;
|
||||
meetingSessionDetails.disabledReason = isCancelled
|
||||
? "BOOKING_CANCELLED"
|
||||
: isRejected
|
||||
? "BOOKING_REJECTED"
|
||||
: "BOOKING_PENDING";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mark No-Show Action
|
||||
// Visible only for past or ongoing bookings (not for future/upcoming bookings)
|
||||
// This matches the booking list behavior where Mark No-Show is not shown for upcoming
|
||||
// ============================================================================
|
||||
const canMarkNoShow = (isPast || isOngoing) && !isPending && !isCancelled && !isRejected;
|
||||
const markNoShow: BookingActionResult = {
|
||||
visible: canMarkNoShow,
|
||||
enabled: canMarkNoShow,
|
||||
};
|
||||
|
||||
if (!canMarkNoShow) {
|
||||
markNoShow.visible = false;
|
||||
markNoShow.enabled = false;
|
||||
if (isPending) {
|
||||
markNoShow.disabledReason = "BOOKING_PENDING";
|
||||
} else if (isCancelled) {
|
||||
markNoShow.disabledReason = "BOOKING_CANCELLED";
|
||||
} else if (isRejected) {
|
||||
markNoShow.disabledReason = "BOOKING_REJECTED";
|
||||
} else {
|
||||
markNoShow.disabledReason = "BOOKING_IN_PAST"; // Actually means booking is in future
|
||||
}
|
||||
} else if (!isOnline) {
|
||||
markNoShow.enabled = false;
|
||||
markNoShow.disabledReason = "OFFLINE";
|
||||
} else if (!booking.attendees || booking.attendees.length === 0) {
|
||||
markNoShow.enabled = false;
|
||||
markNoShow.disabledReason = "NO_ATTENDEE_DATA";
|
||||
}
|
||||
|
||||
return {
|
||||
reschedule,
|
||||
rescheduleRequest,
|
||||
cancel,
|
||||
changeLocation,
|
||||
addGuests,
|
||||
viewRecordings,
|
||||
meetingSessionDetails,
|
||||
markNoShow,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper function to get disabled reason message
|
||||
// ============================================================================
|
||||
|
||||
export function getDisabledReasonMessage(reason: DisabledReason): string {
|
||||
const messages: Record<DisabledReason, string> = {
|
||||
BOOKING_IN_PAST: "This booking is in the past",
|
||||
BOOKING_CANCELLED: "This booking has been cancelled",
|
||||
BOOKING_REJECTED: "This booking has been rejected",
|
||||
BOOKING_PENDING: "This booking is pending confirmation",
|
||||
WITHIN_MINIMUM_NOTICE: "Within minimum reschedule notice period",
|
||||
RESCHEDULING_DISABLED: "Rescheduling is disabled for this event type",
|
||||
CANCELLING_DISABLED: "Cancelling is disabled for this event type",
|
||||
GUESTS_DISABLED: "Adding guests is disabled for this event type",
|
||||
NOT_CAL_VIDEO: "Only available for Cal Video meetings",
|
||||
NO_RECORDINGS: "No recordings available for this meeting",
|
||||
NO_ATTENDEE_DATA: "Attendee information not available",
|
||||
SEATED_BOOKING: "Not available for seated bookings",
|
||||
NOT_ORGANIZER: "Only available for organizers",
|
||||
OFFLINE: "You are currently offline",
|
||||
};
|
||||
|
||||
return messages[reason] || "Action not available";
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Deep Links Utility
|
||||
*
|
||||
* This module provides utilities for opening Cal.com web pages from the mobile app.
|
||||
* Used for features that require server-side behavior that cannot be safely reproduced
|
||||
* client-side (e.g., request reschedule).
|
||||
*/
|
||||
import * as Linking from "expo-linking";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
// Default Cal.com web URL - can be overridden for self-hosted instances
|
||||
const DEFAULT_CAL_WEB_URL = "https://app.cal.com";
|
||||
|
||||
/**
|
||||
* Get the Cal.com web URL from environment or use default
|
||||
*/
|
||||
function getCalWebUrl(): string {
|
||||
// In a real implementation, this would read from environment config
|
||||
// For now, use the default Cal.com URL
|
||||
return DEFAULT_CAL_WEB_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a booking detail page in the web browser.
|
||||
* This is used for actions that require the full web experience.
|
||||
*
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
*/
|
||||
export async function openBookingInWeb(bookingUid: string): Promise<void> {
|
||||
const webUrl = getCalWebUrl();
|
||||
const url = `${webUrl}/booking/${bookingUid}`;
|
||||
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url, {
|
||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
|
||||
});
|
||||
} catch {
|
||||
console.error("Failed to open booking in web browser");
|
||||
// Fallback to system browser
|
||||
try {
|
||||
await Linking.openURL(url);
|
||||
} catch {
|
||||
Alert.alert("Error", "Failed to open booking in browser. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the request reschedule flow in the web browser.
|
||||
* Since request reschedule is a server-driven operation with complex side effects
|
||||
* (emails, webhooks, calendar updates), we open the web page where the user can
|
||||
* trigger it safely.
|
||||
*
|
||||
* Note: The web flow uses a dialog (RescheduleDialog.tsx) that calls
|
||||
* trpc.viewer.bookings.requestReschedule.useMutation. There's no direct URL route
|
||||
* for request reschedule, so we open the booking detail page.
|
||||
*
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
*/
|
||||
export async function openRequestRescheduleInWeb(bookingUid: string): Promise<void> {
|
||||
// Show an informational alert before opening the web page
|
||||
Alert.alert(
|
||||
"Request Reschedule",
|
||||
"You will be redirected to the web app to request a reschedule. This ensures all notifications and calendar updates are handled correctly.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Open in Browser",
|
||||
onPress: () => openBookingInWeb(bookingUid),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the reschedule page for an attendee to pick a new time.
|
||||
* This is the public reschedule page that attendees use.
|
||||
*
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
* @param rescheduleUid - The reschedule token/uid (optional)
|
||||
*/
|
||||
export async function openReschedulePage(
|
||||
bookingUid: string,
|
||||
rescheduleUid?: string
|
||||
): Promise<void> {
|
||||
const webUrl = getCalWebUrl();
|
||||
const url = rescheduleUid
|
||||
? `${webUrl}/reschedule/${rescheduleUid}`
|
||||
: `${webUrl}/booking/${bookingUid}`;
|
||||
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url, {
|
||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
|
||||
});
|
||||
} catch {
|
||||
console.error("Failed to open reschedule page in web browser");
|
||||
try {
|
||||
await Linking.openURL(url);
|
||||
} catch {
|
||||
Alert.alert("Error", "Failed to open reschedule page. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the cancel booking page in the web browser.
|
||||
* This is a fallback for cases where the API cancel doesn't work.
|
||||
*
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
*/
|
||||
export async function openCancelBookingInWeb(bookingUid: string): Promise<void> {
|
||||
const webUrl = getCalWebUrl();
|
||||
const url = `${webUrl}/booking/${bookingUid}`;
|
||||
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url, {
|
||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
|
||||
});
|
||||
} catch {
|
||||
console.error("Failed to open cancel booking page in web browser");
|
||||
try {
|
||||
await Linking.openURL(url);
|
||||
} catch {
|
||||
Alert.alert("Error", "Failed to open booking page. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user