From d51237844c2e7108512cf2d1bb259a4a04a7ba4e Mon Sep 17 00:00:00 2001
From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Date: Thu, 25 Dec 2025 09:20:14 +0530
Subject: [PATCH] feat(companion): implement comprehensive booking actions
system (#26185)
---
companion/app/(tabs)/(bookings)/_layout.tsx | 6 +
.../app/(tabs)/(bookings)/add-guests.tsx | 110 +++
.../app/(tabs)/(bookings)/booking-detail.tsx | 225 +++++-
.../app/(tabs)/(bookings)/edit-location.tsx | 110 +++
.../app/(tabs)/(bookings)/mark-no-show.tsx | 115 +++
.../(bookings)/meeting-session-details.tsx | 84 +++
.../app/(tabs)/(bookings)/reschedule.tsx | 109 +++
.../app/(tabs)/(bookings)/view-recordings.tsx | 75 ++
companion/app/(tabs)/(event-types)/index.tsx | 106 ++-
companion/app/(tabs)/more.tsx | 2 +-
companion/app/event-type-detail.tsx | 10 +-
companion/app/oauth/callback.tsx | 8 +-
.../components/BookingActionsModal.ios.tsx | 331 +++++++++
companion/components/BookingActionsModal.tsx | 502 +++++++------
companion/components/GlassModalHeader.tsx | 82 +++
.../booking-list-item/BookingListItem.ios.tsx | 36 +-
.../booking-list-item/BookingListItem.tsx | 15 +-
.../booking-list-screen/BookingListScreen.tsx | 158 ++--
.../booking-modals/BookingModals.tsx | 181 ++---
.../components/screens/AddGuestsScreen.tsx | 220 ++++++
.../screens/BookingDetailScreen.tsx | 515 +++++++------
.../screens/EditLocationScreen.ios.tsx | 303 ++++++++
.../components/screens/EditLocationScreen.tsx | 351 +++++++++
.../components/screens/MarkNoShowScreen.tsx | 251 +++++++
.../screens/MeetingSessionDetailsScreen.tsx | 159 ++++
.../components/screens/RescheduleScreen.tsx | 379 ++++++++++
.../screens/ViewRecordingsScreen.tsx | 106 +++
companion/hooks/index.ts | 1 +
companion/hooks/useBookingActionModals.ts | 337 +++++++++
companion/hooks/useBookingActions.ts | 86 ++-
companion/hooks/useBookingActionsGating.ts | 119 +++
companion/hooks/useNetworkStatus.ts | 41 ++
companion/services/calcom.ts | 253 ++++++-
companion/services/types/bookings.types.ts | 94 +++
companion/services/types/event-types.types.ts | 8 +-
companion/tsconfig.json | 2 +-
companion/utils/booking-actions.ts | 685 ++++++++++++++++++
companion/utils/deep-links.ts | 128 ++++
38 files changed, 5538 insertions(+), 765 deletions(-)
create mode 100644 companion/app/(tabs)/(bookings)/add-guests.tsx
create mode 100644 companion/app/(tabs)/(bookings)/edit-location.tsx
create mode 100644 companion/app/(tabs)/(bookings)/mark-no-show.tsx
create mode 100644 companion/app/(tabs)/(bookings)/meeting-session-details.tsx
create mode 100644 companion/app/(tabs)/(bookings)/reschedule.tsx
create mode 100644 companion/app/(tabs)/(bookings)/view-recordings.tsx
create mode 100644 companion/components/BookingActionsModal.ios.tsx
create mode 100644 companion/components/GlassModalHeader.tsx
create mode 100644 companion/components/screens/AddGuestsScreen.tsx
create mode 100644 companion/components/screens/EditLocationScreen.ios.tsx
create mode 100644 companion/components/screens/EditLocationScreen.tsx
create mode 100644 companion/components/screens/MarkNoShowScreen.tsx
create mode 100644 companion/components/screens/MeetingSessionDetailsScreen.tsx
create mode 100644 companion/components/screens/RescheduleScreen.tsx
create mode 100644 companion/components/screens/ViewRecordingsScreen.tsx
create mode 100644 companion/hooks/useBookingActionModals.ts
create mode 100644 companion/hooks/useBookingActionsGating.ts
create mode 100644 companion/hooks/useNetworkStatus.ts
create mode 100644 companion/utils/booking-actions.ts
create mode 100644 companion/utils/deep-links.ts
diff --git a/companion/app/(tabs)/(bookings)/_layout.tsx b/companion/app/(tabs)/(bookings)/_layout.tsx
index 44898bcd33..77a440612d 100644
--- a/companion/app/(tabs)/(bookings)/_layout.tsx
+++ b/companion/app/(tabs)/(bookings)/_layout.tsx
@@ -6,6 +6,12 @@ export default function BookingsLayout() {
+
+
+
+
+
+
);
}
diff --git a/companion/app/(tabs)/(bookings)/add-guests.tsx b/companion/app/(tabs)/(bookings)/add-guests.tsx
new file mode 100644
index 0000000000..4fc93c3f8c
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/add-guests.tsx
@@ -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(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const addGuestsScreenRef = useRef(null);
+
+ useEffect(() => {
+ if (uid) {
+ setIsLoading(true);
+ CalComAPIService.getBookingByUid(uid)
+ .then(setBooking)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load booking details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Booking ID is missing");
+ router.back();
+ }
+ }, [uid, router]);
+
+ const handleSave = useCallback(() => {
+ addGuestsScreenRef.current?.submit();
+ }, []);
+
+ const handleAddGuestsSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const renderHeaderRight = useCallback(
+ () => (
+
+ Save
+
+ ),
+ [handleSave, isSaving]
+ );
+
+ if (isLoading) {
+ return (
+ <>
+
+
+ {/* iOS-only Stack.Header */}
+ {Platform.OS === "ios" && (
+
+ Add Guests
+
+ )}
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {Platform.OS === "ios" && (
+
+ Add Guests
+
+
+
+ Save
+
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/companion/app/(tabs)/(bookings)/booking-detail.tsx b/companion/app/(tabs)/(bookings)/booking-detail.tsx
index 1a9d1e13f0..21a33754a6 100644
--- a/companion/app/(tabs)/(bookings)/booking-detail.tsx
+++ b/companion/app/(tabs)/(bookings)/booking-detail.tsx
@@ -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(null);
- if (!uid) {
- return null;
- }
+ // Ref to store action handlers from BookingDetailScreen
+ const actionHandlersRef = useRef(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() {
-
+
+
+ {/* Action Modals for iOS header menu */}
>
);
}
diff --git a/companion/app/(tabs)/(bookings)/edit-location.tsx b/companion/app/(tabs)/(bookings)/edit-location.tsx
new file mode 100644
index 0000000000..8eeee16237
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/edit-location.tsx
@@ -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(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const editLocationScreenRef = useRef(null);
+
+ useEffect(() => {
+ if (uid) {
+ setIsLoading(true);
+ CalComAPIService.getBookingByUid(uid)
+ .then(setBooking)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load booking details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Booking ID is missing");
+ router.back();
+ }
+ }, [uid, router]);
+
+ const handleSave = useCallback(() => {
+ editLocationScreenRef.current?.submit();
+ }, []);
+
+ const handleUpdateSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const renderHeaderRight = useCallback(
+ () => (
+
+ Save
+
+ ),
+ [handleSave, isSaving]
+ );
+
+ if (isLoading) {
+ return (
+ <>
+
+
+ {/* iOS-only Stack.Header */}
+ {Platform.OS === "ios" && (
+
+ Edit Location
+
+ )}
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {Platform.OS === "ios" && (
+
+ Edit Location
+
+
+
+ Save
+
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/companion/app/(tabs)/(bookings)/mark-no-show.tsx b/companion/app/(tabs)/(bookings)/mark-no-show.tsx
new file mode 100644
index 0000000000..a568d1a5eb
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/mark-no-show.tsx
@@ -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(null);
+ const [attendees, setAttendees] = useState([]);
+ 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 (
+ <>
+
+
+ {/* iOS-only Stack.Header */}
+ {Platform.OS === "ios" && (
+
+ Mark No-Show
+
+ )}
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {/* iOS-only Stack.Header with native styling */}
+ {Platform.OS === "ios" && (
+
+ Mark No-Show
+
+ )}
+
+ {
+ 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);
+ }}
+ />
+ >
+ );
+}
diff --git a/companion/app/(tabs)/(bookings)/meeting-session-details.tsx b/companion/app/(tabs)/(bookings)/meeting-session-details.tsx
new file mode 100644
index 0000000000..2c17d402ff
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/meeting-session-details.tsx
@@ -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(null);
+ const [sessions, setSessions] = useState([]);
+ 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 (
+ <>
+
+
+ {/* iOS-only Stack.Header */}
+ {Platform.OS === "ios" && (
+
+ Session Details
+
+ )}
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {/* iOS-only Stack.Header with native styling */}
+ {Platform.OS === "ios" && (
+
+ Session Details
+
+ )}
+
+
+ >
+ );
+}
diff --git a/companion/app/(tabs)/(bookings)/reschedule.tsx b/companion/app/(tabs)/(bookings)/reschedule.tsx
new file mode 100644
index 0000000000..280e17cfcb
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/reschedule.tsx
@@ -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(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const rescheduleScreenRef = useRef(null);
+
+ useEffect(() => {
+ if (uid) {
+ setIsLoading(true);
+ CalComAPIService.getBookingByUid(uid)
+ .then(setBooking)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load booking details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Booking ID is missing");
+ router.back();
+ }
+ }, [uid, router]);
+
+ const handleSave = useCallback(() => {
+ rescheduleScreenRef.current?.submit();
+ }, []);
+
+ const handleRescheduleSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const renderHeaderRight = useCallback(
+ () => (
+
+ Save
+
+ ),
+ [handleSave, isSaving]
+ );
+
+ if (isLoading) {
+ return (
+ <>
+
+
+ {Platform.OS === "ios" && (
+
+ Reschedule
+
+ )}
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {Platform.OS === "ios" && (
+
+ Reschedule
+
+
+
+ Save
+
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/companion/app/(tabs)/(bookings)/view-recordings.tsx b/companion/app/(tabs)/(bookings)/view-recordings.tsx
new file mode 100644
index 0000000000..9f52ac2963
--- /dev/null
+++ b/companion/app/(tabs)/(bookings)/view-recordings.tsx
@@ -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([]);
+ 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 (
+ <>
+
+
+ {/* iOS-only Stack.Header */}
+ {Platform.OS === "ios" && (
+
+ Recordings
+
+ )}
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {/* iOS-only Stack.Header with native styling */}
+ {Platform.OS === "ios" && (
+
+ Recordings
+
+ )}
+
+
+ >
+ );
+}
diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx
index d4a8bf653c..c7d52939bd 100644
--- a/companion/app/(tabs)/(event-types)/index.tsx
+++ b/companion/app/(tabs)/(event-types)/index.tsx
@@ -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 (
-
-
-
+ {Platform.OS === "web" && }
@@ -410,9 +408,7 @@ export default function EventTypes() {
if (error) {
return (
-
-
-
+ {Platform.OS === "web" && }
@@ -430,9 +426,7 @@ export default function EventTypes() {
if (eventTypes.length === 0) {
return (
-
-
-
+ {Platform.OS === "web" && }
-
-
-
-
-
-
- New
-
-
-
+ {Platform.OS === "web" && (
+ <>
+
+
+
+
+
+ New
+
+
+ >
+ )}
-
-
-
-
-
-
- New
-
-
-
+ {(Platform.OS === "web" || Platform.OS === "android") && (
+ <>
+
+
+
+
+
+ New
+
+
+ >
+ )}
(
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]" : ""
}`}
diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx
index b626401db0..702da16edd 100644
--- a/companion/app/event-type-detail.tsx
+++ b/companion/app/event-type-detail.tsx
@@ -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);
}
}
diff --git a/companion/app/oauth/callback.tsx b/companion/app/oauth/callback.tsx
index ea5207021d..7fe90790a6 100644
--- a/companion/app/oauth/callback.tsx
+++ b/companion/app/oauth/callback.tsx
@@ -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("/");
}
}
}
diff --git a/companion/components/BookingActionsModal.ios.tsx b/companion/components/BookingActionsModal.ios.tsx
new file mode 100644
index 0000000000..bc95a2e712
--- /dev/null
+++ b/companion/components/BookingActionsModal.ios.tsx
@@ -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 = {
+ 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 (
+ {
+ 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}
+ >
+
+
+
+
+ {label}
+
+ {!enabled && (
+
+ Unavailable
+
+ )}
+
+ );
+}
+
+interface SectionHeaderProps {
+ title: string;
+}
+
+function SectionHeader({ title }: SectionHeaderProps) {
+ return (
+
+ {title}
+
+ );
+}
+
+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 (
+
+
+ e.stopPropagation()}
+ style={{ paddingBottom: insets.bottom || 16 }}
+ >
+ {/* Actions Card */}
+
+ {/* Booking Title Header */}
+
+
+ {booking.title}
+
+
+
+
+ {/* Edit Event Section */}
+ {hasEditEventActions && (
+ <>
+
+ {editEventActions.map((action, index) => (
+
+ ))}
+ >
+ )}
+
+ {/* After Event Section */}
+ {hasAfterEventActions && (
+ <>
+
+ {afterEventActions.map((action, index) => (
+
+ ))}
+ >
+ )}
+
+ {/* Danger Zone Section */}
+ {dangerZoneActions.length > 0 && (
+ <>
+
+ {dangerZoneActions.map((action, index) => (
+
+ ))}
+ >
+ )}
+
+
+
+ {/* Cancel Button */}
+
+
+ Cancel
+
+
+
+
+
+ );
+}
diff --git a/companion/components/BookingActionsModal.tsx b/companion/components/BookingActionsModal.tsx
index 57a460d615..9374b42358 100644
--- a/companion/components/BookingActionsModal.tsx
+++ b/companion/components/BookingActionsModal.tsx
@@ -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 = {
+ 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 (
+ {
+ 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}
+ >
+
+
+
+
+ {label}
+
+ {!enabled && (
+
+ Unavailable
+
+ )}
+
+ );
+}
+
+interface SectionHeaderProps {
+ title: string;
+}
+
+function SectionHeader({ title }: SectionHeaderProps) {
+ return (
+
+
+ {title}
+
+
+ );
+}
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 (
e.stopPropagation()}
>
- {/* Actions List */}
-
- {/* Edit event label */}
-
- Edit event
+ {/* Actions Card */}
+
+ {/* Booking Title Header */}
+
+
+ {booking.title}
+
- {/* Reschedule Booking */}
- {
- if (editEventActionsDisabled) return;
- onClose();
- onReschedule();
- }}
- disabled={editEventActionsDisabled}
- className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
- >
-
-
- Reschedule Booking
-
-
+
+ {/* Edit Event Section */}
+ {hasEditEventActions && (
+ <>
+
+ {editEventActions.map((action, index) => (
+
+ ))}
+ >
+ )}
- {/* Edit Location */}
- {
- if (editEventActionsDisabled) return;
- onClose();
- onEditLocation();
- }}
- disabled={editEventActionsDisabled}
- className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
- >
-
-
- Edit Location
-
-
+ {/* After Event Section */}
+ {hasAfterEventActions && (
+ <>
+
+ {afterEventActions.map((action, index) => (
+
+ ))}
+ >
+ )}
- {/* Add Guests */}
- {
- if (editEventActionsDisabled) return;
- onClose();
- onAddGuests();
- }}
- disabled={editEventActionsDisabled}
- className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
- >
-
-
- Add Guests
-
-
+ {/* Danger Zone Section */}
+ {dangerZoneActions.length > 0 && (
+ <>
+
+ {dangerZoneActions.map((action, index) => (
+
+ ))}
+ >
+ )}
+
+
- {/* Separator */}
-
-
- {/* After event label */}
-
- After event
+ {/* Cancel Button */}
+
+
+ Cancel
-
- {/* View Recordings */}
- {hasLocationUrl ? (
- {
- if (afterEventActionsDisabled) return;
- onClose();
- onViewRecordings();
- }}
- disabled={afterEventActionsDisabled}
- className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
- >
-
-
- View Recordings
-
-
- ) : null}
-
- {/* Meeting Session Details */}
- {hasLocationUrl ? (
- {
- if (afterEventActionsDisabled) return;
- onClose();
- onMeetingSessionDetails();
- }}
- disabled={afterEventActionsDisabled}
- className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
- >
-
-
- Meeting Session Details
-
-
- ) : null}
-
- {/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */}
- {
- if (isUpcoming || isUnconfirmed) return;
- onClose();
- onMarkNoShow();
- }}
- disabled={isUpcoming || isUnconfirmed}
- className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
- >
-
-
- Mark as No-Show
-
-
-
- {/* Separator */}
-
-
- {/* Report Booking */}
- {
- onClose();
- onReportBooking();
- }}
- className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
- >
-
-
- Report Booking
-
-
-
- {/* Separator */}
-
-
- {/* Cancel Booking */}
- {
- if (cancelBookingDisabled) return;
- onClose();
- onCancelBooking();
- }}
- disabled={cancelBookingDisabled}
- className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
- >
-
-
- Cancel Event
-
-
-
-
- {/* Cancel button */}
-
-
- Cancel
-
-
+
diff --git a/companion/components/GlassModalHeader.tsx b/companion/components/GlassModalHeader.tsx
new file mode 100644
index 0000000000..d88e976e1e
--- /dev/null
+++ b/companion/components/GlassModalHeader.tsx
@@ -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 (
+
+
+ {/* Back Button - matching native iOS style */}
+
+
+
+
+ {/* Title - centered */}
+
+ {title}
+
+
+ {/* Action Button - matching senior's Stack.Header.Menu style */}
+ {onAction ? (
+
+ {actionLoading ? (
+
+ ) : actionIcon ? (
+ // Icon button style (like the three-dot menu)
+
+ ) : (
+ // Text button style (like "Save", "Done")
+
+ {actionLabel}
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/companion/components/booking-list-item/BookingListItem.ios.tsx b/companion/components/booking-list-item/BookingListItem.ios.tsx
index 09eca44244..a5d89e5f81 100644
--- a/companion/components/booking-list-item/BookingListItem.ios.tsx
+++ b/companion/components/booking-list-item/BookingListItem.ios.tsx
@@ -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 = ({
booking,
@@ -41,6 +42,22 @@ export const BookingListItem: React.FC = ({
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 = ({
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 = ({
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 = ({
) : null}
{/* Host and Attendees */}
{hostAndAttendeesDisplay ? (
- {hostAndAttendeesDisplay}
+
+ {hostAndAttendeesDisplay}
+ {hasNoShowAttendee && (
+
+
+ No-show
+
+ )}
+
) : null}
{/* Meeting Link */}
{meetingInfo ? (
diff --git a/companion/components/booking-list-item/BookingListItem.tsx b/companion/components/booking-list-item/BookingListItem.tsx
index de2de5b568..aee39ea08e 100644
--- a/companion/components/booking-list-item/BookingListItem.tsx
+++ b/companion/components/booking-list-item/BookingListItem.tsx
@@ -29,6 +29,11 @@ export const BookingListItem: React.FC = ({
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 (
= ({
) : null}
{/* Host and Attendees */}
{hostAndAttendeesDisplay ? (
- {hostAndAttendeesDisplay}
+
+ {hostAndAttendeesDisplay}
+ {hasNoShowAttendee && (
+
+
+ No-show
+
+ )}
+
) : null}
{/* Meeting Link */}
{meetingInfo ? (
diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx
index c937d2069b..1708284dc9 100644
--- a/companion/components/booking-list-screen/BookingListScreen.tsx
+++ b/companion/components/booking-list-screen/BookingListScreen.tsx
@@ -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 = ({
// 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 = ({
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 = ({
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 = ({
{/* Modals */}
{}}
+ 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 = ({
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 */}
>
);
};
diff --git a/companion/components/booking-modals/BookingModals.tsx b/companion/components/booking-modals/BookingModals.tsx
index 9c66c67666..3991d0f40a 100644
--- a/companion/components/booking-modals/BookingModals.tsx
+++ b/companion/components/booking-modals/BookingModals.tsx
@@ -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;
// 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 = ({
showRescheduleModal,
rescheduleBooking,
- rescheduleDate,
- rescheduleTime,
- rescheduleReason,
isRescheduling,
onRescheduleClose,
onRescheduleSubmit,
- onRescheduleDateChange,
- onRescheduleTimeChange,
- onRescheduleReasonChange,
showRejectModal,
rejectReason,
isDeclining,
@@ -81,7 +92,25 @@ export const BookingModals: React.FC = ({
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 = ({
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 */}
-
-
- {rescheduleBooking ? (
- <>
-
- Reschedule "{rescheduleBooking.title}"
-
-
- {/* Date Input */}
-
-
- New Date (YYYY-MM-DD)
-
-
-
-
- {/* Time Input */}
-
-
- New Time (HH:MM, 24-hour format)
-
-
-
-
- {/* Reason Input */}
-
- Reason (optional)
-
-
-
- {/* Submit Button */}
-
- {isRescheduling ? (
-
- ) : (
-
- Reschedule Booking
-
- )}
-
-
- {/* Cancel Button */}
-
- Cancel
-
- >
- ) : null}
-
-
-
{/* Reject Booking Modal */}
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(
+ function AddGuestsScreen({ booking, onSuccess, onSavingChange }, ref) {
+ const insets = useSafeAreaInsets();
+ const [email, setEmail] = useState("");
+ const [name, setName] = useState("");
+ const [guests, setGuests] = useState>([]);
+ 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 (
+
+ No booking data
+
+ );
+ }
+
+ return (
+
+
+ {/* Info note */}
+
+
+
+ Guests will receive an email notification about this booking.
+
+
+
+ {/* Form Card */}
+
+ {/* Email input */}
+
+ Email *
+
+
+
+ {/* Name input */}
+
+ Name (optional)
+
+
+
+
+ {/* Add button */}
+
+
+ Add Guest
+
+
+ {/* Guest list */}
+ {guests.length > 0 && (
+
+
+ Guests to add ({guests.length})
+
+
+ {guests.map((guest, index) => (
+
+
+
+
+
+ {guest.email}
+ {guest.name && (
+ {guest.name}
+ )}
+
+ handleRemoveGuest(index)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ disabled={isSaving}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+ }
+);
+
+// Default export for React Native compatibility
+export default AddGuestsScreen;
diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx
index 3a4ec14afc..855b7963f0 100644
--- a/companion/components/screens/BookingDetailScreen.tsx
+++ b/companion/components/screens/BookingDetailScreen.tsx
@@ -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 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(null);
const [error, setError] = useState(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 (
@@ -422,21 +519,45 @@ export function BookingDetailScreen({ uid }: BookingDetailScreenProps) {
) : null}
{booking.attendees && booking.attendees.length > 0 ? (
- {booking.attendees.map((attendee, index) => (
- 0 ? "mt-4" : ""}`}>
-
-
- {getInitials(attendee.name)}
-
+ {booking.attendees.map((attendee, index) => {
+ const isNoShow =
+ (attendee as any).noShow === true || (attendee as any).absent === true;
+ return (
+ 0 ? "mt-4" : ""}`}>
+
+
+ {getInitials(attendee.name)}
+
+
+
+
+
+ {attendee.name}
+
+ {isNoShow && (
+
+
+
+ No-show
+
+
+ )}
+
+
+ {attendee.email}
+
+
-
-
- {attendee.name}
-
- {attendee.email}
-
-
- ))}
+ );
+ })}
) : null}
@@ -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 */}
- setShowRescheduleModal(false)}
- >
- setShowRescheduleModal(false)}
- >
- e.stopPropagation()}
- style={shadows.xl()}
- >
- {/* Header */}
-
-
-
-
-
-
-
- Reschedule Booking
-
-
- Select a new date and time for this booking.
-
-
-
-
-
- {/* Content */}
-
- {/* Date Input */}
-
-
- New Date
- (YYYY-MM-DD)
-
-
-
-
- {/* Time Input */}
-
-
- New Time
- (HH:MM, 24-hour)
-
-
-
-
- {/* Reason Input */}
-
-
- Reason
- (Optional)
-
-
-
-
-
- {/* Footer */}
-
-
- {
- setShowRescheduleModal(false);
- setRescheduleDate("");
- setRescheduleTime("");
- setRescheduleReason("");
- }}
- disabled={rescheduling}
- >
- Cancel
-
-
- {rescheduling ? (
-
- ) : (
- Reschedule
- )}
-
-
-
-
-
-
+ {/* Cancelling overlay */}
+ {isCancelling && (
+
+
+
+
+ Cancelling booking...
+
+
+
+ )}
>
);
diff --git a/companion/components/screens/EditLocationScreen.ios.tsx b/companion/components/screens/EditLocationScreen.ios.tsx
new file mode 100644
index 0000000000..9ea974ddb8
--- /dev/null
+++ b/companion/components/screens/EditLocationScreen.ios.tsx
@@ -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(
+ function EditLocationScreen({ booking, onSuccess, onSavingChange }, ref) {
+ const insets = useSafeAreaInsets();
+ const [selectedType, setSelectedType] = useState("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 (
+
+ No booking data
+
+ );
+ }
+
+ return (
+
+
+ {/* Current Location Info */}
+ {booking.location && (
+
+
+
+
+
+ Current Location
+
+ {booking.location}
+
+
+
+ )}
+
+ {/* Location Type Selector with Native Context Menu */}
+
+ Location Type
+
+
+
+
+
+
+
+ {selectedTypeConfig.label}
+
+
+ {selectedTypeConfig.description}
+
+
+
+ {/* Native iOS Context Menu Button */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Location Input */}
+
+ {selectedTypeConfig.label}
+
+
+
+
+
+ {/* Info note */}
+
+
+
+ Updating the location will notify attendees. Note: Calendar events may not be updated
+ automatically.
+
+
+
+
+ );
+ }
+);
+
+// Default export for React Native compatibility
+export default EditLocationScreen;
diff --git a/companion/components/screens/EditLocationScreen.tsx b/companion/components/screens/EditLocationScreen.tsx
new file mode 100644
index 0000000000..596ff1db31
--- /dev/null
+++ b/companion/components/screens/EditLocationScreen.tsx
@@ -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(
+ function EditLocationScreen({ booking, onSuccess, onSavingChange }, ref) {
+ const insets = useSafeAreaInsets();
+ const [selectedType, setSelectedType] = useState("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 (
+
+ No booking data
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {/* Current Location Info */}
+ {booking.location && (
+
+
+
+
+
+ Current Location
+
+ {booking.location}
+
+
+
+ )}
+
+ {/* Location Type Selector */}
+
+ Location Type
+
+ setShowTypePicker(true)}
+ disabled={isSaving}
+ activeOpacity={0.7}
+ >
+
+
+
+
+
+ {selectedTypeConfig.label}
+
+
+ {selectedTypeConfig.description}
+
+
+
+
+
+ {/* Location Input */}
+
+ {selectedTypeConfig.label}
+
+
+
+
+
+ {/* Info note */}
+
+
+
+ Updating the location will notify attendees. Note: Calendar events may not be
+ updated automatically.
+
+
+
+
+
+ {/* Location Type Picker Modal */}
+ setShowTypePicker(false)}
+ >
+ setShowTypePicker(false)}
+ >
+
+
+
+ Select Location Type
+
+
+ item.id}
+ renderItem={({ item }) => {
+ const isSelected = item.id === selectedType;
+ return (
+ {
+ setSelectedType(item.id);
+ if (selectedType !== item.id) {
+ setInputValue("");
+ }
+ setShowTypePicker(false);
+ }}
+ >
+
+
+
+
+
+ {item.label}
+
+ {item.description}
+
+ {isSelected && }
+
+ );
+ }}
+ />
+ setShowTypePicker(false)}
+ >
+ Cancel
+
+
+
+
+ >
+ );
+ }
+);
+
+export default EditLocationScreen;
diff --git a/companion/components/screens/MarkNoShowScreen.tsx b/companion/components/screens/MarkNoShowScreen.tsx
new file mode 100644
index 0000000000..9a89b7340b
--- /dev/null
+++ b/companion/components/screens/MarkNoShowScreen.tsx
@@ -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(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 (
+ handleMarkNoShow(item)}
+ disabled={isProcessing}
+ activeOpacity={0.7}
+ >
+
+
+ {getInitials(item.name)}
+
+
+
+
+ {item.name || "Unknown"}
+
+
+ {item.email}
+
+ {isNoShow && (
+
+
+ Marked as no-show
+
+ )}
+
+ {isProcessing ? (
+
+ ) : (
+
+
+
+
+ {isNoShow ? "Unmark" : "Mark"}
+
+
+
+ )}
+
+ );
+ };
+
+ if (!booking) {
+ return (
+
+ No booking data
+
+ );
+ }
+
+ return (
+
+
+ {/* Info note */}
+
+
+
+ Tap an attendee to mark them as no-show or to undo a previous no-show marking.
+
+
+
+ {safeAttendees.length === 0 ? (
+
+
+
+
+ No attendees found
+
+ Attendee information is not available for this booking.
+
+
+ ) : (
+ <>
+
+ Attendees ({safeAttendees.length})
+
+
+ item.email}
+ showsVerticalScrollIndicator={false}
+ contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
+ />
+
+ >
+ )}
+
+
+ );
+}
+
+// Default export for React Native compatibility
+export default MarkNoShowScreen;
diff --git a/companion/components/screens/MeetingSessionDetailsScreen.tsx b/companion/components/screens/MeetingSessionDetailsScreen.tsx
new file mode 100644
index 0000000000..12d9fed928
--- /dev/null
+++ b/companion/components/screens/MeetingSessionDetailsScreen.tsx
@@ -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 }) => (
+
+ {/* Session header */}
+
+
+
+
+
+
+ Session {sessions.length > 1 ? index + 1 : ""}
+
+
+ {item.duration && (
+
+
+ {formatDuration(item.duration)}
+
+
+ )}
+
+
+ {/* Session details */}
+
+
+
+
+ Started: {formatDate(item.startTime)}
+
+
+ {item.endTime && (
+
+
+
+ Ended: {formatTime(item.endTime)}
+
+
+ )}
+ {item.maxParticipants && (
+
+
+
+ Max participants: {item.maxParticipants}
+
+
+ )}
+
+
+ {/* Participants */}
+ {item.participants && item.participants.length > 0 && (
+
+
+ Participants ({item.participants.length})
+
+ {item.participants.map((participant, pIndex) => (
+
+
+
+
+
+
+ {participant.userName || "Unknown participant"}
+
+
+ Joined: {formatTime(participant.joinTime)}
+ {participant.duration && ` • ${formatDuration(participant.duration)}`}
+
+
+
+ ))}
+
+ )}
+
+ );
+
+ if (sessions.length === 0) {
+ return (
+
+
+
+
+
+ No session details available
+
+
+ Session details may not be available for all meetings.
+
+
+ );
+ }
+
+ return (
+
+
+ item.id}
+ showsVerticalScrollIndicator={false}
+ contentContainerStyle={{ paddingBottom: 16 }}
+ />
+
+
+ );
+}
+
+// Default export for React Native compatibility
+export default MeetingSessionDetailsScreen;
diff --git a/companion/components/screens/RescheduleScreen.tsx b/companion/components/screens/RescheduleScreen.tsx
new file mode 100644
index 0000000000..767a43e9e8
--- /dev/null
+++ b/companion/components/screens/RescheduleScreen.tsx
@@ -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(
+ function RescheduleScreen({ booking, onSuccess, onSavingChange }, ref) {
+ const insets = useSafeAreaInsets();
+ const [selectedDateTime, setSelectedDateTime] = useState(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 (
+
+ No booking data
+
+ );
+ }
+
+ // 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 (
+ <>
+
+
+ {/* Booking Title Card */}
+
+
+
+
+
+ Rescheduling
+
+ {booking.title}
+
+
+
+
+ {/* Form Card */}
+
+ {/* Date picker */}
+ {
+ console.log("[RescheduleScreen] Opening date picker");
+ setShowDatePicker(true);
+ }}
+ disabled={isSaving}
+ activeOpacity={0.7}
+ >
+ New Date
+
+
+ {formattedDate}
+
+
+
+
+
+ {/* Time picker */}
+ {
+ console.log("[RescheduleScreen] Opening time picker");
+ setShowTimePicker(true);
+ }}
+ disabled={isSaving}
+ activeOpacity={0.7}
+ >
+ New Time
+
+
+ {formattedTime}
+
+
+
+
+
+ {/* Reason input */}
+
+
+ Reason (optional)
+
+
+
+
+
+ {/* Info note */}
+
+
+
+ Attendees will receive an email notification about the new time.
+
+
+
+
+
+ {/* Date Picker Modal - Center Dialog */}
+ setShowDatePicker(false)}
+ >
+ setShowDatePicker(false)}
+ >
+
+
+
+ Select Date
+
+
+ {dateOptions.map((option, index) => {
+ const isSelected =
+ option.value.toDateString() === selectedDateTime.toDateString();
+ return (
+ {
+ const newDate = new Date(option.value);
+ newDate.setHours(selectedDateTime.getHours());
+ newDate.setMinutes(selectedDateTime.getMinutes());
+ setSelectedDateTime(newDate);
+ }}
+ activeOpacity={0.7}
+ >
+
+
+ {option.label}
+
+ {isSelected && }
+
+
+ );
+ })}
+
+ setShowDatePicker(false)}
+ >
+ Done
+
+
+
+
+
+
+ {/* Time Picker Modal - Center Dialog */}
+ setShowTimePicker(false)}
+ >
+ setShowTimePicker(false)}
+ >
+
+
+
+ Select Time
+
+
+ {timeOptions.map((option, index) => {
+ const isSelected =
+ selectedDateTime.getHours() === option.value.hour &&
+ selectedDateTime.getMinutes() === option.value.minute;
+ return (
+ {
+ const newDate = new Date(selectedDateTime);
+ newDate.setHours(option.value.hour);
+ newDate.setMinutes(option.value.minute);
+ setSelectedDateTime(newDate);
+ }}
+ activeOpacity={0.7}
+ >
+
+
+ {option.label}
+
+ {isSelected && }
+
+
+ );
+ })}
+
+ setShowTimePicker(false)}
+ >
+ Done
+
+
+
+
+
+ >
+ );
+ }
+);
+
+// Default export for React Native compatibility
+export default RescheduleScreen;
diff --git a/companion/components/screens/ViewRecordingsScreen.tsx b/companion/components/screens/ViewRecordingsScreen.tsx
new file mode 100644
index 0000000000..73d5599cd0
--- /dev/null
+++ b/companion/components/screens/ViewRecordingsScreen.tsx
@@ -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 }) => (
+ handleOpenRecording(item)}
+ activeOpacity={0.7}
+ >
+
+
+
+
+
+ Recording
+ {formatDate(item.startTime)}
+ {item.duration && (
+
+ Duration: {formatDuration(item.duration)}
+
+ )}
+
+
+
+
+ );
+
+ if (recordings.length === 0) {
+ return (
+
+
+
+
+
+ No recordings available
+
+
+ Recordings may take some time to process after the meeting ends.
+
+
+ );
+ }
+
+ return (
+
+
+ item.id}
+ showsVerticalScrollIndicator={false}
+ contentContainerStyle={{ paddingBottom: 16 }}
+ />
+
+
+ );
+}
+
+// Default export for React Native compatibility
+export default ViewRecordingsScreen;
diff --git a/companion/hooks/index.ts b/companion/hooks/index.ts
index b27b058be8..9fdd78cad4 100644
--- a/companion/hooks/index.ts
+++ b/companion/hooks/index.ts
@@ -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";
diff --git a/companion/hooks/useBookingActionModals.ts b/companion/hooks/useBookingActionModals.ts
new file mode 100644
index 0000000000..ed8e7396c8
--- /dev/null
+++ b/companion/hooks/useBookingActionModals.ts
@@ -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;
+
+ // Edit Location Modal
+ showEditLocationModal: boolean;
+ isUpdatingLocation: boolean;
+ openEditLocationModal: (booking: Booking) => void;
+ closeEditLocationModal: () => void;
+ handleUpdateLocation: (location: string) => Promise;
+
+ // 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;
+
+ // Request Reschedule (deep link to web)
+ handleRequestReschedule: (booking: Booking) => void;
+}
+
+export function useBookingActionModals(): UseBookingActionModalsReturn {
+ const queryClient = useQueryClient();
+
+ // Selected booking state
+ const [selectedBooking, setSelectedBooking] = useState(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([]);
+
+ // Meeting Session Details Modal state
+ const [showMeetingSessionDetailsModal, setShowMeetingSessionDetailsModal] = useState(false);
+ const [isLoadingSessions, setIsLoadingSessions] = useState(false);
+ const [sessions, setSessions] = useState([]);
+
+ // 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,
+ };
+}
diff --git a/companion/hooks/useBookingActions.ts b/companion/hooks/useBookingActions.ts
index 2226df686c..415eaabfb2 100644
--- a/companion/hooks/useBookingActions.ts
+++ b/companion/hooks/useBookingActions.ts
@@ -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 => {
+ 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,
diff --git a/companion/hooks/useBookingActionsGating.ts b/companion/hooks/useBookingActionsGating.ts
new file mode 100644
index 0000000000..7e1c2c77fa
--- /dev/null
+++ b/companion/hooks/useBookingActionsGating.ts
@@ -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]);
+}
diff --git a/companion/hooks/useNetworkStatus.ts b/companion/hooks/useNetworkStatus.ts
new file mode 100644
index 0000000000..56dca19cfe
--- /dev/null
+++ b/companion/hooks/useNetworkStatus.ts
@@ -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 };
+}
diff --git a/companion/services/calcom.ts b/companion/services/calcom.ts
index cf44ab1f0f..5e3c7f0964 100644
--- a/companion/services/calcom.ts
+++ b/companion/services/calcom.ts
@@ -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 {
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 {
+ try {
+ const response = await this.makeRequest(
+ `/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 {
+ try {
+ const response = await this.makeRequest(
+ `/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 {
+ try {
+ const response = await this.makeRequest(
+ `/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 {
+ try {
+ const response = await this.makeRequest(
+ `/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 {
+ try {
+ const response = await this.makeRequest(
+ `/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 {
+ try {
+ const response = await this.makeRequest(
+ `/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 {
try {
// Get current user to extract username
diff --git a/companion/services/types/bookings.types.ts b/companion/services/types/bookings.types.ts
index 95fc37686c..994b540a2b 100644
--- a/companion/services/types/bookings.types.ts
+++ b/companion/services/types/bookings.types.ts
@@ -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;
+}
diff --git a/companion/services/types/event-types.types.ts b/companion/services/types/event-types.types.ts
index a83610c03c..99ea5c9e26 100644
--- a/companion/services/types/event-types.types.ts
+++ b/companion/services/types/event-types.types.ts
@@ -218,7 +218,13 @@ export interface EventType {
}>;
// Metadata
- metadata?: Record;
+ metadata?: Record;
+
+ // Booking action settings (may also be in metadata)
+ disableRescheduling?: boolean;
+ disableCancelling?: boolean;
+ minimumRescheduleNotice?: number;
+ allowReschedulingPastBookings?: boolean;
}
export interface CreateEventTypeInput {
diff --git a/companion/tsconfig.json b/companion/tsconfig.json
index 871ce9c924..fddcdece9c 100644
--- a/companion/tsconfig.json
+++ b/companion/tsconfig.json
@@ -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"]
}
diff --git a/companion/utils/booking-actions.ts b/companion/utils/booking-actions.ts
new file mode 100644
index 0000000000..15de1a100b
--- /dev/null
+++ b/companion/utils/booking-actions.ts
@@ -0,0 +1,685 @@
+/**
+ * Centralized Booking Action Gating Utility
+ *
+ * This module provides a single source of truth for booking action visibility and enabled state.
+ * It matches the web's semantics from apps/web/components/booking/actions/bookingActions.ts
+ *
+ * Parity Scope (In Scope Now):
+ * - cancel, reschedule, reschedule_request, change_location, add_guests
+ * - view_recordings, meeting_session_details, mark_no_show
+ *
+ * Deferred:
+ * - report, reroute, reassign, charge_card
+ */
+import type { Booking } from "../services/types/bookings.types";
+import type { EventType } from "../services/types/event-types.types";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface BookingActionResult {
+ visible: boolean;
+ enabled: boolean;
+ disabledReason?: DisabledReason;
+ needsData?: string[];
+}
+
+export type DisabledReason =
+ | "BOOKING_IN_PAST"
+ | "BOOKING_CANCELLED"
+ | "BOOKING_REJECTED"
+ | "BOOKING_PENDING"
+ | "WITHIN_MINIMUM_NOTICE"
+ | "RESCHEDULING_DISABLED"
+ | "CANCELLING_DISABLED"
+ | "GUESTS_DISABLED"
+ | "NOT_CAL_VIDEO"
+ | "NO_RECORDINGS"
+ | "NO_ATTENDEE_DATA"
+ | "SEATED_BOOKING"
+ | "NOT_ORGANIZER"
+ | "OFFLINE";
+
+export interface BookingActionsResult {
+ reschedule: BookingActionResult;
+ rescheduleRequest: BookingActionResult;
+ cancel: BookingActionResult;
+ changeLocation: BookingActionResult;
+ addGuests: BookingActionResult;
+ viewRecordings: BookingActionResult;
+ meetingSessionDetails: BookingActionResult;
+ markNoShow: BookingActionResult;
+}
+
+export interface BookingActionContext {
+ booking: Booking | NormalizedBooking;
+ eventType?: EventType | null;
+ currentUserId?: number;
+ currentUserEmail?: string;
+ isOnline?: boolean;
+}
+
+export interface NormalizedBooking {
+ id: number;
+ uid: string;
+ title: string;
+ status: BookingStatus;
+ startTime: Date;
+ endTime: Date;
+ location?: string;
+ isRecorded?: boolean;
+ rescheduled?: boolean;
+ fromReschedule?: string;
+ recurringEventId?: string;
+ eventTypeId?: number;
+ user?: {
+ id: number;
+ email: string;
+ name: string;
+ };
+ hosts?: Array<{
+ id?: number | string;
+ email?: string;
+ name?: string;
+ }>;
+ attendees?: Array<{
+ id?: number | string;
+ email: string;
+ name: string;
+ noShow?: boolean;
+ }>;
+ seatsReferences?: Array<{
+ referenceUid: string;
+ attendee?: {
+ email: string;
+ };
+ }>;
+ payment?: Array<{
+ id: number;
+ success: boolean;
+ paymentOption: string;
+ }>;
+}
+
+export type BookingStatus = "ACCEPTED" | "PENDING" | "CANCELLED" | "REJECTED";
+
+// ============================================================================
+// Normalization Functions
+// ============================================================================
+
+/**
+ * Normalizes a booking object from API response to a consistent format.
+ * - Normalizes status to uppercase
+ * - Converts time strings to Date objects
+ * - Ensures consistent field names
+ */
+export function normalizeBooking(booking: Booking): NormalizedBooking {
+ // Normalize status to uppercase (API v2 may return lowercase)
+ const status = (booking.status?.toUpperCase() || "PENDING") as BookingStatus;
+
+ // Normalize times - prefer startTime/endTime, fallback to start/end
+ const startTimeStr = booking.startTime || booking.start || "";
+ const endTimeStr = booking.endTime || booking.end || "";
+
+ return {
+ id: booking.id,
+ uid: booking.uid,
+ title: booking.title,
+ status,
+ startTime: new Date(startTimeStr),
+ endTime: new Date(endTimeStr),
+ location: booking.location,
+ isRecorded: (booking as any).isRecorded,
+ rescheduled: booking.rescheduled,
+ fromReschedule: booking.fromReschedule,
+ recurringEventId: booking.recurringEventId,
+ eventTypeId: booking.eventTypeId,
+ user: booking.user,
+ hosts: booking.hosts,
+ attendees: booking.attendees,
+ seatsReferences: (booking as any).seatsReferences,
+ payment: booking.payment,
+ };
+}
+
+// ============================================================================
+// Time Computation Functions (matching web's exact definitions)
+// ============================================================================
+
+/**
+ * Check if booking is in the past.
+ * Web definition: new Date(booking.endTime) < new Date()
+ */
+export function isBookingInPast(booking: NormalizedBooking, now: Date = new Date()): boolean {
+ return booking.endTime < now;
+}
+
+/**
+ * Check if booking is upcoming.
+ * Web definition: new Date(booking.endTime) >= new Date()
+ */
+export function isBookingUpcoming(booking: NormalizedBooking, now: Date = new Date()): boolean {
+ return booking.endTime >= now;
+}
+
+/**
+ * Check if booking is currently ongoing.
+ * Web definition: isUpcoming && new Date() >= new Date(booking.startTime)
+ */
+export function isBookingOngoing(booking: NormalizedBooking, now: Date = new Date()): boolean {
+ return isBookingUpcoming(booking, now) && now >= booking.startTime;
+}
+
+/**
+ * Check if booking is cancelled.
+ */
+export function isBookingCancelled(booking: NormalizedBooking): boolean {
+ return booking.status === "CANCELLED";
+}
+
+/**
+ * Check if booking is rejected.
+ */
+export function isBookingRejected(booking: NormalizedBooking): boolean {
+ return booking.status === "REJECTED";
+}
+
+/**
+ * Check if booking is pending (unconfirmed).
+ */
+export function isBookingPending(booking: NormalizedBooking): boolean {
+ return booking.status === "PENDING";
+}
+
+/**
+ * Check if booking is confirmed (accepted).
+ */
+export function isBookingConfirmed(booking: NormalizedBooking): boolean {
+ return booking.status === "ACCEPTED";
+}
+
+// ============================================================================
+// Role Detection Functions
+// ============================================================================
+
+/**
+ * Check if the current user is the organizer of the booking.
+ */
+export function isUserOrganizer(
+ booking: NormalizedBooking,
+ userId?: number,
+ userEmail?: string
+): boolean {
+ if (!booking.user) return false;
+
+ if (userId && booking.user.id === userId) return true;
+ if (userEmail && booking.user.email?.toLowerCase() === userEmail.toLowerCase()) return true;
+
+ return false;
+}
+
+/**
+ * Check if the current user is a host of the booking.
+ */
+export function isUserHost(
+ booking: NormalizedBooking,
+ userId?: number,
+ userEmail?: string
+): boolean {
+ if (!booking.hosts || booking.hosts.length === 0) return false;
+
+ return booking.hosts.some((host) => {
+ if (userId && host.id !== undefined && String(host.id) === String(userId)) return true;
+ if (userEmail && host.email?.toLowerCase() === userEmail.toLowerCase()) return true;
+ return false;
+ });
+}
+
+/**
+ * Check if the current user is an attendee of the booking.
+ */
+export function isUserAttendee(
+ booking: NormalizedBooking,
+ userId?: number,
+ userEmail?: string
+): boolean {
+ if (!booking.attendees || booking.attendees.length === 0) return false;
+
+ return booking.attendees.some((attendee) => {
+ if (userId && attendee.id !== undefined && String(attendee.id) === String(userId)) return true;
+ if (userEmail && attendee.email?.toLowerCase() === userEmail.toLowerCase()) return true;
+ return false;
+ });
+}
+
+/**
+ * Get the user's seat reference UID if they are an attendee of a seated booking.
+ */
+export function getUserSeatReferenceUid(
+ booking: NormalizedBooking,
+ userEmail?: string
+): string | undefined {
+ if (!booking.seatsReferences || !userEmail) return undefined;
+
+ const userSeat = booking.seatsReferences.find(
+ (seat) => seat.attendee?.email?.toLowerCase() === userEmail.toLowerCase()
+ );
+
+ return userSeat?.referenceUid;
+}
+
+/**
+ * Check if booking is a seated booking.
+ */
+export function isSeatedBooking(booking: NormalizedBooking): boolean {
+ return Boolean(booking.seatsReferences && booking.seatsReferences.length > 0);
+}
+
+// ============================================================================
+// Location Detection Functions
+// ============================================================================
+
+/**
+ * Check if the booking location is Cal Video.
+ * Web definition: !booking.location || booking.location === "integrations:daily" || location.trim() === ""
+ */
+export function isCalVideoLocation(booking: NormalizedBooking): boolean {
+ const location = booking.location;
+
+ if (!location) return true;
+ if (location === "integrations:daily") return true;
+ if (typeof location === "string" && location.trim() === "") return true;
+ if (location.includes("cal.com/video") || location.includes("cal-video")) return true;
+
+ return false;
+}
+
+// ============================================================================
+// Minimum Reschedule Notice Check
+// ============================================================================
+
+/**
+ * Check if the booking is within the minimum reschedule notice period.
+ * This should only apply to non-organizers (attendees).
+ */
+export function isWithinMinimumRescheduleNotice(
+ bookingStartTime: Date,
+ minimumRescheduleNotice: number | null | undefined,
+ now: Date = new Date()
+): boolean {
+ if (!minimumRescheduleNotice || minimumRescheduleNotice <= 0) return false;
+
+ const noticeEndTime = new Date(bookingStartTime.getTime() - minimumRescheduleNotice * 60 * 1000);
+ return now >= noticeEndTime;
+}
+
+// ============================================================================
+// Main Action Gating Function
+// ============================================================================
+
+/**
+ * Helper to check if a booking is already normalized (has Date objects for times)
+ */
+function isNormalizedBooking(booking: Booking | NormalizedBooking): booking is NormalizedBooking {
+ return booking.startTime instanceof Date;
+}
+
+/**
+ * Get the visibility and enabled state for all booking actions.
+ * This is the main entry point for action gating.
+ * Accepts either a raw Booking or NormalizedBooking and normalizes internally.
+ */
+export function getBookingActions(context: BookingActionContext): BookingActionsResult {
+ const {
+ booking: rawBooking,
+ eventType,
+ currentUserId,
+ currentUserEmail,
+ isOnline = true,
+ } = context;
+ const now = new Date();
+
+ // Normalize booking if needed (convert string times to Date objects)
+ const booking: NormalizedBooking = isNormalizedBooking(rawBooking)
+ ? rawBooking
+ : normalizeBooking(rawBooking);
+
+ // Compute booking state flags
+ const isPast = isBookingInPast(booking, now);
+ const isOngoing = isBookingOngoing(booking, now);
+ const isCancelled = isBookingCancelled(booking);
+ const isRejected = isBookingRejected(booking);
+ const isPending = isBookingPending(booking);
+ const isConfirmed = isBookingConfirmed(booking);
+ const isCalVideo = isCalVideoLocation(booking);
+ const isSeated = isSeatedBooking(booking);
+
+ // Compute user role flags
+ const isOrganizer = isUserOrganizer(booking, currentUserId, currentUserEmail);
+ const isHost = isUserHost(booking, currentUserId, currentUserEmail);
+ const isOrganizerOrHost = isOrganizer || isHost;
+
+ // Get event type settings (with defaults)
+ const disableRescheduling = eventType?.disableRescheduling ?? false;
+ const disableCancelling = eventType?.disableCancelling ?? false;
+ const disableGuests = eventType?.disableGuests ?? false;
+ const minimumRescheduleNotice = eventType?.minimumRescheduleNotice ?? null;
+ const allowReschedulingPastBookings = eventType?.allowReschedulingPastBookings ?? false;
+
+ // Check minimum reschedule notice (only applies to non-organizers)
+ const withinMinimumNotice =
+ !isOrganizerOrHost &&
+ isWithinMinimumRescheduleNotice(booking.startTime, minimumRescheduleNotice, now);
+
+ // ============================================================================
+ // Reschedule Action
+ // Visible for upcoming, non-cancelled, non-pending bookings
+ // ============================================================================
+ const isUpcoming = !isPast;
+ const canReschedule =
+ (isUpcoming || allowReschedulingPastBookings) &&
+ !isCancelled &&
+ !isRejected &&
+ !isPending &&
+ !disableRescheduling;
+ const reschedule: BookingActionResult = {
+ visible: isUpcoming || allowReschedulingPastBookings,
+ enabled: canReschedule,
+ needsData: eventType ? undefined : ["eventType"],
+ };
+
+ if (!isOnline) {
+ reschedule.enabled = false;
+ reschedule.disabledReason = "OFFLINE";
+ } else if (isCancelled) {
+ reschedule.visible = false;
+ reschedule.enabled = false;
+ reschedule.disabledReason = "BOOKING_CANCELLED";
+ } else if (isRejected) {
+ reschedule.visible = false;
+ reschedule.enabled = false;
+ reschedule.disabledReason = "BOOKING_REJECTED";
+ } else if (isPending) {
+ reschedule.visible = false;
+ reschedule.enabled = false;
+ reschedule.disabledReason = "BOOKING_PENDING";
+ } else if (isPast && !allowReschedulingPastBookings) {
+ reschedule.visible = false;
+ reschedule.enabled = false;
+ reschedule.disabledReason = "BOOKING_IN_PAST";
+ } else if (disableRescheduling) {
+ reschedule.enabled = false;
+ reschedule.disabledReason = "RESCHEDULING_DISABLED";
+ } else if (withinMinimumNotice) {
+ reschedule.enabled = false;
+ reschedule.disabledReason = "WITHIN_MINIMUM_NOTICE";
+ }
+
+ // ============================================================================
+ // Reschedule Request Action (for organizers to request attendee to reschedule)
+ // Visible only for organizers/hosts with upcoming, non-cancelled, non-pending bookings
+ // ============================================================================
+ const canRequestReschedule =
+ isOrganizerOrHost &&
+ (isUpcoming || allowReschedulingPastBookings) &&
+ !isCancelled &&
+ !isRejected &&
+ !isPending &&
+ !disableRescheduling &&
+ !isSeated;
+ const rescheduleRequest: BookingActionResult = {
+ visible: isOrganizerOrHost && (isUpcoming || allowReschedulingPastBookings),
+ enabled: canRequestReschedule,
+ needsData: eventType ? undefined : ["eventType"],
+ };
+
+ if (!isOrganizerOrHost) {
+ rescheduleRequest.visible = false;
+ rescheduleRequest.enabled = false;
+ rescheduleRequest.disabledReason = "NOT_ORGANIZER";
+ } else if (!isOnline) {
+ rescheduleRequest.enabled = false;
+ rescheduleRequest.disabledReason = "OFFLINE";
+ } else if (isCancelled) {
+ rescheduleRequest.visible = false;
+ rescheduleRequest.enabled = false;
+ rescheduleRequest.disabledReason = "BOOKING_CANCELLED";
+ } else if (isRejected) {
+ rescheduleRequest.visible = false;
+ rescheduleRequest.enabled = false;
+ rescheduleRequest.disabledReason = "BOOKING_REJECTED";
+ } else if (isPending) {
+ rescheduleRequest.visible = false;
+ rescheduleRequest.enabled = false;
+ rescheduleRequest.disabledReason = "BOOKING_PENDING";
+ } else if (isPast && !allowReschedulingPastBookings) {
+ rescheduleRequest.visible = false;
+ rescheduleRequest.enabled = false;
+ rescheduleRequest.disabledReason = "BOOKING_IN_PAST";
+ } else if (disableRescheduling) {
+ rescheduleRequest.enabled = false;
+ rescheduleRequest.disabledReason = "RESCHEDULING_DISABLED";
+ } else if (isSeated) {
+ // Web disables reschedule_request for seated bookings
+ rescheduleRequest.enabled = false;
+ rescheduleRequest.disabledReason = "SEATED_BOOKING";
+ }
+
+ // ============================================================================
+ // Cancel Action
+ // Visible for upcoming, non-cancelled bookings
+ // ============================================================================
+ const canCancel = isUpcoming && !isCancelled && !isRejected && !disableCancelling;
+ const cancel: BookingActionResult = {
+ visible: canCancel,
+ enabled: canCancel,
+ };
+
+ if (!canCancel) {
+ if (isCancelled) {
+ cancel.visible = false;
+ cancel.enabled = false;
+ cancel.disabledReason = "BOOKING_CANCELLED";
+ } else if (isRejected) {
+ cancel.visible = false;
+ cancel.enabled = false;
+ cancel.disabledReason = "BOOKING_REJECTED";
+ } else if (isPast) {
+ cancel.visible = false;
+ cancel.enabled = false;
+ cancel.disabledReason = "BOOKING_IN_PAST";
+ } else if (disableCancelling) {
+ cancel.enabled = false;
+ cancel.disabledReason = "CANCELLING_DISABLED";
+ }
+ } else if (!isOnline) {
+ cancel.enabled = false;
+ cancel.disabledReason = "OFFLINE";
+ }
+
+ // ============================================================================
+ // Change Location Action
+ // Visible for upcoming, non-cancelled, non-pending bookings
+ // ============================================================================
+ const canChangeLocation = isUpcoming && !isCancelled && !isRejected && !isPending;
+ const changeLocation: BookingActionResult = {
+ visible: canChangeLocation,
+ enabled: canChangeLocation,
+ };
+
+ if (!canChangeLocation) {
+ changeLocation.visible = false;
+ changeLocation.enabled = false;
+ if (isCancelled) {
+ changeLocation.disabledReason = "BOOKING_CANCELLED";
+ } else if (isRejected) {
+ changeLocation.disabledReason = "BOOKING_REJECTED";
+ } else if (isPending) {
+ changeLocation.disabledReason = "BOOKING_PENDING";
+ } else if (isPast) {
+ changeLocation.disabledReason = "BOOKING_IN_PAST";
+ }
+ } else if (!isOnline) {
+ changeLocation.enabled = false;
+ changeLocation.disabledReason = "OFFLINE";
+ }
+
+ // ============================================================================
+ // Add Guests Action
+ // Visible for upcoming, non-cancelled, non-pending bookings (if guests enabled)
+ // ============================================================================
+ const canAddGuests = isUpcoming && !isCancelled && !isRejected && !isPending && !disableGuests;
+ const addGuests: BookingActionResult = {
+ visible: canAddGuests,
+ enabled: canAddGuests,
+ needsData: eventType ? undefined : ["eventType"],
+ };
+
+ if (disableGuests) {
+ addGuests.visible = false;
+ addGuests.enabled = false;
+ addGuests.disabledReason = "GUESTS_DISABLED";
+ } else if (!canAddGuests) {
+ addGuests.visible = false;
+ addGuests.enabled = false;
+ if (isCancelled) {
+ addGuests.disabledReason = "BOOKING_CANCELLED";
+ } else if (isRejected) {
+ addGuests.disabledReason = "BOOKING_REJECTED";
+ } else if (isPending) {
+ addGuests.disabledReason = "BOOKING_PENDING";
+ } else if (isPast) {
+ addGuests.disabledReason = "BOOKING_IN_PAST";
+ }
+ } else if (!isOnline) {
+ addGuests.enabled = false;
+ addGuests.disabledReason = "OFFLINE";
+ }
+
+ // ============================================================================
+ // View Recordings Action
+ // Only visible for past Cal Video bookings
+ // Note: We show this for all past Cal Video bookings, not just those with isRecorded=true,
+ // because the isRecorded field may not always be reliable. The view-recordings screen
+ // will handle showing an empty state if there are no recordings.
+ // ============================================================================
+ const canViewRecordings = isCalVideo && isPast && isConfirmed && !isCancelled && !isRejected;
+ const viewRecordings: BookingActionResult = {
+ visible: canViewRecordings,
+ enabled: canViewRecordings,
+ };
+
+ if (!isCalVideo) {
+ viewRecordings.visible = false;
+ viewRecordings.enabled = false;
+ viewRecordings.disabledReason = "NOT_CAL_VIDEO";
+ } else if (!isPast) {
+ viewRecordings.visible = false;
+ viewRecordings.enabled = false;
+ viewRecordings.disabledReason = "BOOKING_IN_PAST";
+ } else if (!isConfirmed || isCancelled || isRejected) {
+ viewRecordings.visible = false;
+ viewRecordings.enabled = false;
+ viewRecordings.disabledReason = isCancelled
+ ? "BOOKING_CANCELLED"
+ : isRejected
+ ? "BOOKING_REJECTED"
+ : "BOOKING_PENDING";
+ }
+
+ // ============================================================================
+ // Meeting Session Details Action
+ // Only visible for past Cal Video bookings
+ // ============================================================================
+ const canViewSessionDetails = isCalVideo && isPast && isConfirmed && !isCancelled && !isRejected;
+ const meetingSessionDetails: BookingActionResult = {
+ visible: canViewSessionDetails,
+ enabled: canViewSessionDetails,
+ };
+
+ if (!isCalVideo) {
+ meetingSessionDetails.visible = false;
+ meetingSessionDetails.enabled = false;
+ meetingSessionDetails.disabledReason = "NOT_CAL_VIDEO";
+ } else if (!isPast) {
+ meetingSessionDetails.visible = false;
+ meetingSessionDetails.enabled = false;
+ meetingSessionDetails.disabledReason = "BOOKING_IN_PAST";
+ } else if (!isConfirmed || isCancelled || isRejected) {
+ meetingSessionDetails.visible = false;
+ meetingSessionDetails.enabled = false;
+ meetingSessionDetails.disabledReason = isCancelled
+ ? "BOOKING_CANCELLED"
+ : isRejected
+ ? "BOOKING_REJECTED"
+ : "BOOKING_PENDING";
+ }
+
+ // ============================================================================
+ // Mark No-Show Action
+ // Visible only for past or ongoing bookings (not for future/upcoming bookings)
+ // This matches the booking list behavior where Mark No-Show is not shown for upcoming
+ // ============================================================================
+ const canMarkNoShow = (isPast || isOngoing) && !isPending && !isCancelled && !isRejected;
+ const markNoShow: BookingActionResult = {
+ visible: canMarkNoShow,
+ enabled: canMarkNoShow,
+ };
+
+ if (!canMarkNoShow) {
+ markNoShow.visible = false;
+ markNoShow.enabled = false;
+ if (isPending) {
+ markNoShow.disabledReason = "BOOKING_PENDING";
+ } else if (isCancelled) {
+ markNoShow.disabledReason = "BOOKING_CANCELLED";
+ } else if (isRejected) {
+ markNoShow.disabledReason = "BOOKING_REJECTED";
+ } else {
+ markNoShow.disabledReason = "BOOKING_IN_PAST"; // Actually means booking is in future
+ }
+ } else if (!isOnline) {
+ markNoShow.enabled = false;
+ markNoShow.disabledReason = "OFFLINE";
+ } else if (!booking.attendees || booking.attendees.length === 0) {
+ markNoShow.enabled = false;
+ markNoShow.disabledReason = "NO_ATTENDEE_DATA";
+ }
+
+ return {
+ reschedule,
+ rescheduleRequest,
+ cancel,
+ changeLocation,
+ addGuests,
+ viewRecordings,
+ meetingSessionDetails,
+ markNoShow,
+ };
+}
+
+// ============================================================================
+// Helper function to get disabled reason message
+// ============================================================================
+
+export function getDisabledReasonMessage(reason: DisabledReason): string {
+ const messages: Record = {
+ 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";
+}
diff --git a/companion/utils/deep-links.ts b/companion/utils/deep-links.ts
new file mode 100644
index 0000000000..41b7b9a747
--- /dev/null
+++ b/companion/utils/deep-links.ts
@@ -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 {
+ 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 {
+ // 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 {
+ 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 {
+ 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.");
+ }
+ }
+}