feat(companion): implement comprehensive booking actions system (#26185)

This commit is contained in:
Dhairyashil Shinde
2025-12-25 04:50:14 +01:00
committed by GitHub
parent b002d7d797
commit d51237844c
38 changed files with 5538 additions and 765 deletions
@@ -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} />
</>
);
}
+52 -54
View File
@@ -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" }}
+1 -1
View File
@@ -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]" : ""
}`}
+6 -4
View File
@@ -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);
}
}
+4 -4
View File
@@ -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>
);
}
+274 -228
View File
@@ -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>
+82
View File
@@ -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;
+1
View File
@@ -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";
+337
View File
@@ -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,
};
}
+81 -5
View File
@@ -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,
+119
View File
@@ -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]);
}
+41
View File
@@ -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 };
}
+251 -2
View File
@@ -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 {
+1 -1
View File
@@ -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"]
}
+685
View File
@@ -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";
}
+128
View File
@@ -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.");
}
}
}