refactor: (companion) (iOS) create an ios screen for specific native logic (#26081)

* create an ios screen for specific native logic

* feat: update bookings layout to conditionally show header on iOS

* feat: add useActiveBookingFilter hook for managing booking filter state

- Introduced a new hook, useActiveBookingFilter, to manage the active booking filter state and provide related utilities.
- Added filter options for "upcoming", "unconfirmed", "past", and "cancelled" bookings.
- Updated index.ts to export the new hook and its types for UI state management.

* refactor: streamline booking filter management and remove unused code

- Integrated the useActiveBookingFilter hook to manage booking filter state, simplifying the filter logic.
- Removed redundant state variables and functions related to booking filters.
- Cleaned up the code by eliminating unused imports and commented-out code sections.
- Updated the booking fetching logic to utilize the new filter parameters directly.

* refactor: move meeting URL extraction and info retrieval to utility module

- Extracted meeting URL extraction and meeting info retrieval logic into a new utility module, meetings-utils.ts.
- Updated index.ios.tsx and index.tsx to utilize the new getMeetingInfo function from the utility module, simplifying the component code.
- Removed redundant helper functions from the component files to enhance maintainability.

* refactor: extract empty state content logic into utility module

- Moved the getEmptyStateContent function from the Bookings component files to a new utility module, bookings-utils.ts, to enhance code reusability and maintainability.
- Updated index.ios.tsx and index.tsx to utilize the new utility function, simplifying the component code and improving clarity.
- Removed redundant empty state logic from the component files.

* refactor: enhance booking filtering and utility functions

- Updated the Bookings component to utilize new utility functions for filtering bookings by event type and search queries, improving code clarity and maintainability.
- Moved formatting functions (formatTime, formatDate, formatMonthYear, getMonthYearKey) and grouping logic (groupBookingsByMonth) to the bookings-utils module, centralizing related functionality.
- Removed redundant code from the Bookings component files, streamlining the overall structure.

* refactor: implement booking actions hook for improved management

- Introduced a new `useBookingActions` hook to centralize booking-related actions such as rescheduling, confirming, and rejecting bookings, enhancing code organization and reusability.
- Updated the `Bookings` component to utilize the new hook, simplifying the component logic and reducing redundancy.
- Removed outdated state management and functions related to booking actions, streamlining the overall code structure.

* refactor: consolidate booking components and enhance structure

- Refactored the Bookings component to utilize a new BookingListScreen component, improving code organization and readability.
- Introduced BookingListItem for individual booking rendering, streamlining the rendering logic and enhancing maintainability.
- Removed redundant state management and functions from the Bookings component, simplifying the overall structure.
- Updated modal handling for booking actions, rescheduling, and rejection to improve user experience and code clarity.

* feat: add BookingListItem component for enhanced booking display

- Introduced a new BookingListItem component for rendering individual booking details, improving modularity and reusability.
- Implemented context menu actions for booking management, including rescheduling, editing location, and adding guests.
- Updated type definitions for BookingListItemProps to include additional action handlers for better functionality.
- Refactored existing BookingListItem component to utilize the new props structure, enhancing clarity and maintainability.

* refactor: simplify layout and styling in BookingListItem and BookingListScreen components

- Updated the BookingListItem component to streamline the layout by reducing padding and adjusting the gap between elements.
- Modified the BookingListScreen component to remove unnecessary horizontal padding in the content container style, enhancing the overall layout consistency.
This commit is contained in:
Beto
2025-12-21 11:38:42 +00:00
committed by GitHub
parent 51bce6763f
commit aa127b2c2b
16 changed files with 2498 additions and 1536 deletions
@@ -0,0 +1,10 @@
import { Stack } from "expo-router";
import { Platform } from "react-native";
export default function BookingsLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ headerShown: Platform.OS !== "ios" ? false : true }} />
</Stack>
);
}
@@ -0,0 +1,179 @@
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack } from "expo-router";
import React, { useState } from "react";
import type { NativeStackHeaderItemMenuAction } from "@react-navigation/native-stack";
import { BookingListScreen } from "../../../components/booking-list-screen/BookingListScreen";
import { useActiveBookingFilter } from "../../../hooks/useActiveBookingFilter";
import { useEventTypes } from "../../../hooks";
export default function Bookings() {
const [searchQuery, setSearchQuery] = useState("");
const [selectedEventTypeId, setSelectedEventTypeId] = useState<number | null>(null);
const { data: eventTypes } = useEventTypes();
// Use the active booking filter hook
const { activeFilter, filterOptions, filterParams, handleFilterChange } = useActiveBookingFilter(
"upcoming",
() => {
// Clear dependent filters when status filter changes
setSearchQuery("");
setSelectedEventTypeId(null);
}
);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const clearEventTypeFilter = () => {
setSelectedEventTypeId(null);
};
const buildActiveBookingFilter = () => {
const currentFilterOption = filterOptions.find((option) => option.key === activeFilter);
const statusMenuItems: NativeStackHeaderItemMenuAction[] = filterOptions.map((option) => {
const isSelected = activeFilter === option.key;
return {
type: "action",
label: option.label,
icon: {
name:
option.key === "upcoming"
? "calendar.badge.clock"
: option.key === "unconfirmed"
? "calendar.badge.exclamationmark"
: option.key === "past"
? "calendar.badge.checkmark"
: "calendar.badge.minus",
type: "sfSymbol",
},
state: isSelected ? "on" : "off",
onPress: () => {
handleFilterChange(option.key);
},
} satisfies NativeStackHeaderItemMenuAction;
});
return {
type: "menu" as const,
label: currentFilterOption?.label || "Filter",
labelStyle: {
fontWeight: "600",
color: "#007AFF",
},
menu: {
title: "Filter by Status",
items: statusMenuItems,
},
};
};
const buildEventTypeFilterMenu = () => {
const eventTypeMenuItems: NativeStackHeaderItemMenuAction[] = (eventTypes || []).map(
(eventType) => {
const isSelected = selectedEventTypeId === eventType.id;
return {
type: "action",
label: eventType.title,
icon: {
name: "calendar.badge.clock",
type: "sfSymbol",
},
state: isSelected ? "on" : "off",
onPress: () => {
// Toggle behavior: if already selected, clear filter; otherwise, select it
if (isSelected) {
clearEventTypeFilter();
} else {
setSelectedEventTypeId(eventType.id);
}
},
};
}
);
const clearFilterItem: NativeStackHeaderItemMenuAction[] =
selectedEventTypeId !== null
? [
{
type: "action",
label: "Clear filter",
icon: {
name: "xmark.circle",
type: "sfSymbol",
},
onPress: () => {
clearEventTypeFilter();
},
},
]
: [];
const menuItems = [...eventTypeMenuItems, ...clearFilterItem];
return {
type: "menu" as const,
label: "Filter by Event Type",
icon: {
name: "line.3.horizontal.decrease",
type: "sfSymbol",
},
labelStyle: {
fontWeight: "600",
color: "#007AFF",
},
menu: {
title: menuItems.length > 0 ? "Filter by Event Type" : "No Event Types",
items: [
...menuItems,
// Show message if no event types available
...(menuItems.length === 0
? [
{
type: "action",
label: "No event types available",
onPress: () => {},
} satisfies NativeStackHeaderItemMenuAction,
]
: []),
],
},
};
};
return (
<>
<Stack.Screen
options={{
title: "Bookings",
headerBlurEffect: isLiquidGlassAvailable() ? undefined : "light",
headerStyle: {
backgroundColor: "transparent",
},
headerLargeTitleEnabled: true,
headerSearchBarOptions: {
placeholder: "Search bookings",
onChangeText: (e) => handleSearch(e.nativeEvent.text),
obscureBackground: false,
barTintColor: "#fff",
},
unstable_headerRightItems: () => {
const eventTypeFilterMenu = buildEventTypeFilterMenu();
const statusFilterMenu = buildActiveBookingFilter();
return [eventTypeFilterMenu, statusFilterMenu];
},
}}
/>
<BookingListScreen
searchQuery={searchQuery}
selectedEventTypeId={selectedEventTypeId}
activeFilter={activeFilter}
filterParams={filterParams}
iosStyle={true}
/>
</>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { Ionicons } from "@expo/vector-icons";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import SegmentedControl from "@react-native-segmented-control/segmented-control";
import React, { useState } from "react";
import { View, Text, TouchableOpacity, TextInput } from "react-native";
import type { EventType } from "../../../services/calcom";
import { CalComAPIService } from "../../../services/calcom";
import { Header } from "../../../components/Header";
import { BookingListScreen } from "../../../components/booking-list-screen/BookingListScreen";
import { useActiveBookingFilter } from "../../../hooks/useActiveBookingFilter";
export default function Bookings() {
const [searchQuery, setSearchQuery] = useState("");
const [showFilterModal, setShowFilterModal] = useState(false);
const [eventTypes, setEventTypes] = useState<EventType[]>([]);
const [selectedEventTypeId, setSelectedEventTypeId] = useState<number | null>(null);
const [selectedEventTypeLabel, setSelectedEventTypeLabel] = useState<string | null>(null);
const [eventTypesLoading, setEventTypesLoading] = useState(false);
// Use the active booking filter hook
const { activeFilter, filterLabels, activeIndex, filterParams, handleSegmentChange } =
useActiveBookingFilter("upcoming", () => {
// Clear dependent filters when status filter changes
setSearchQuery("");
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
});
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const fetchEventTypes = async () => {
try {
setEventTypesLoading(true);
const types = await CalComAPIService.getEventTypes();
setEventTypes(types);
} catch (err) {
console.error("Error fetching event types:", err);
// Error is logged but not displayed to user for event type filter
} finally {
setEventTypesLoading(false);
}
};
const handleFilterButtonPress = () => {
setShowFilterModal(true);
if (eventTypes.length === 0) {
fetchEventTypes();
}
};
const clearEventTypeFilter = () => {
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
};
const handleEventTypeSelect = (eventTypeId: number | null, label?: string | null) => {
if (eventTypeId === null) {
clearEventTypeFilter();
} else {
setSelectedEventTypeId(eventTypeId);
setSelectedEventTypeLabel(label || null);
}
setShowFilterModal(false);
};
const supportsLiquidGlass = isLiquidGlassAvailable();
const renderSegmentedControl = () => {
const segmentedControlContent = (
<SegmentedControl
values={filterLabels}
selectedIndex={activeIndex}
onChange={handleSegmentChange}
style={{ height: 40 }}
appearance="light"
activeFontStyle={{ color: "#007AFF", fontWeight: "600", fontSize: 14 }}
fontStyle={{ color: "#8E8E93", fontSize: 14 }}
/>
);
return (
<>
{supportsLiquidGlass ? (
<GlassView
glassEffectStyle="regular"
style={{ paddingHorizontal: 8, paddingVertical: 12 }}
>
{segmentedControlContent}
</GlassView>
) : (
<View className="border-b border-gray-200 bg-white px-2 py-3 md:px-4">
{segmentedControlContent}
</View>
)}
<View className="border-b border-gray-300 bg-gray-100 px-2 py-2 md:px-4">
<View className="flex-row items-center gap-3">
<TouchableOpacity
className="flex-row items-center rounded-lg border border-gray-200 bg-white"
style={{ width: "20%", paddingHorizontal: 8, paddingVertical: 6 }}
onPress={handleFilterButtonPress}
>
<Ionicons name="options-outline" size={14} color="#333" />
<Text className="text-sm text-[#333]" style={{ marginLeft: 4 }}>
Filter
</Text>
</TouchableOpacity>
<View style={{ width: "75%" }}>
<TextInput
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-black"
placeholder="Search bookings"
placeholderTextColor="#8E8E93"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
</View>
</View>
{selectedEventTypeId !== null ? (
<View className="mt-2 flex-row items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2">
<Text className="flex-1 text-sm text-[#333]">
Filtered by {selectedEventTypeLabel || "event type"}
</Text>
<TouchableOpacity onPress={clearEventTypeFilter}>
<Text className="text-sm font-semibold text-[#007AFF]">Clear filter</Text>
</TouchableOpacity>
</View>
) : null}
</View>
</>
);
};
return (
<BookingListScreen
renderHeader={() => <Header />}
renderFilterControls={renderSegmentedControl}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
eventTypes={eventTypes}
eventTypesLoading={eventTypesLoading}
searchQuery={searchQuery}
selectedEventTypeId={selectedEventTypeId}
onEventTypeChange={handleEventTypeSelect}
activeFilter={activeFilter}
filterParams={filterParams}
/>
);
}
@@ -3,7 +3,7 @@ import { Stack } from "expo-router";
export default function EventTypesLayout() {
return (
<Stack>
<Stack.Screen name="event-types" options={{}} />
<Stack.Screen name="index" options={{}} />
</Stack>
);
}
+3 -3
View File
@@ -20,7 +20,7 @@ export default function TabLayout() {
<NativeTabs.Trigger.Label>Event Types</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="bookings">
<NativeTabs.Trigger name="(bookings)">
<NativeTabs.Trigger.Icon
sf="calendar"
src={<VectorIcon family={MaterialCommunityIcons} name="calendar" />}
@@ -76,7 +76,7 @@ function WebTabs() {
/>
<Tabs.Screen
name="bookings"
name="(bookings)"
options={{
title: "Bookings",
tabBarIcon: ({ color, focused }) => (
@@ -86,7 +86,7 @@ function WebTabs() {
/>
<Tabs.Screen
name="availability"
name="(availability)"
options={{
title: "Availability",
tabBarIcon: ({ color, focused }) => (
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,262 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { View, Text, TouchableOpacity, Linking, Pressable, Alert } from "react-native";
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 type { BookingListItemProps } from "./types";
import { SvgImage } from "../SvgImage";
import { getMeetingInfo } from "../../utils/meetings-utils";
import { formatTime, formatDate, getHostAndAttendeesDisplay } from "../../utils/bookings-utils";
import { showErrorAlert } from "../../utils/alerts";
export const BookingListItem: React.FC<BookingListItemProps> = ({
booking,
userEmail,
isConfirming,
isDeclining,
onPress,
onLongPress,
onConfirm,
onReject,
onActionsPress,
onReschedule,
onEditLocation,
onAddGuests,
onViewRecordings,
onMeetingSessionDetails,
onMarkNoShow,
onReportBooking,
onCancelBooking,
}) => {
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isUpcoming = new Date(endTime) >= new Date();
const isPending = booking.status?.toUpperCase() === "PENDING";
const isCancelled = booking.status?.toUpperCase() === "CANCELLED";
const isRejected = booking.status?.toUpperCase() === "REJECTED";
const hasLocationUrl = !!booking.location;
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
const meetingInfo = getMeetingInfo(booking.location);
// Define context menu actions based on booking state
type ContextMenuAction = {
label: string;
icon: string;
onPress: () => void;
role: "default" | "destructive";
};
const allActions: (ContextMenuAction & { visible: boolean })[] = [
// Edit Event Section
{
label: "Reschedule Booking",
icon: "calendar",
onPress: () => onReschedule?.(booking),
role: "default",
visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
},
{
label: "Edit Location",
icon: "location",
onPress: () => onEditLocation?.(booking),
role: "default",
visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
},
{
label: "Add Guests",
icon: "person.badge.plus",
onPress: () => onAddGuests?.(booking),
role: "default",
visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
},
// After Event Section
{
label: "View Recordings",
icon: "video",
onPress: () => onViewRecordings?.(booking),
role: "default",
visible: hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onViewRecordings,
},
{
label: "Meeting Session Details",
icon: "info.circle",
onPress: () => onMeetingSessionDetails?.(booking),
role: "default",
visible:
hasLocationUrl && !isUpcoming && !isCancelled && !isPending && !!onMeetingSessionDetails,
},
{
label: "Mark as No-Show",
icon: "eye.slash",
onPress: () => onMarkNoShow?.(booking),
role: "default",
visible: !isUpcoming && !isPending && !!onMarkNoShow,
},
// Other Actions
{
label: "Report Booking",
icon: "flag",
onPress: () => onReportBooking?.(booking),
role: "destructive",
visible: !!onReportBooking,
},
{
label: "Cancel Event",
icon: "xmark.circle",
onPress: () => onCancelBooking?.(booking),
role: "destructive",
visible: isUpcoming && !isCancelled && !!onCancelBooking,
},
];
const contextMenuActions: ContextMenuAction[] = allActions
.filter((action) => action.visible)
.map(({ label, icon, onPress, role }) => ({ label, icon, onPress, role }));
return (
<View className="border-b border-[#E5E5EA] bg-white">
<Pressable
onPress={() => onPress(booking)}
onLongPress={() => onLongPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
className="active:bg-[#F8F9FA]"
>
{/* Time and Date Row */}
<View className="mb-2 flex-row flex-wrap items-center">
<Text className="text-sm font-medium text-[#333]">
{formatDate(startTime, isUpcoming)}
</Text>
<Text className="ml-2 text-sm text-[#666]">
{formatTime(startTime)} - {formatTime(endTime)}
</Text>
</View>
{/* Badges Row */}
<View className="mb-3 flex-row flex-wrap items-center">
{isPending ? (
<View className="mb-1 mr-2 rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
</View>
) : null}
</View>
{/* Title */}
<Text
className={`mb-2 text-lg font-medium leading-5 text-[#333] ${isCancelled || isRejected ? "line-through" : ""}`}
numberOfLines={2}
>
{booking.title}
</Text>
{/* Description */}
{booking.description ? (
<Text className="mb-2 text-sm leading-5 text-[#666]" numberOfLines={1}>
&quot;{booking.description}&quot;
</Text>
) : null}
{/* Host and Attendees */}
{hostAndAttendeesDisplay ? (
<Text className="mb-2 text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
) : null}
{/* Meeting Link */}
{meetingInfo ? (
<View className="mb-1 flex-row">
<TouchableOpacity
className="flex-row items-center"
style={{ alignSelf: "flex-start" }}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
onPress={async (e) => {
e.stopPropagation();
try {
await Linking.openURL(meetingInfo.cleanUrl);
} catch {
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
}
}}
>
{meetingInfo.iconUrl ? (
<SvgImage
uri={meetingInfo.iconUrl}
width={16}
height={16}
style={{ marginRight: 6 }}
/>
) : (
<Ionicons name="videocam" size={16} color="#007AFF" style={{ marginRight: 6 }} />
)}
<Text className="text-sm font-medium text-[#007AFF]">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
) : null}
</Pressable>
<View className="flex-row items-center justify-end gap-2">
{isPending ? (
<>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onConfirm(booking);
}}
>
<Ionicons name="checkmark" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Confirm</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onReject(booking);
}}
>
<Ionicons name="close" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Reject</Text>
</TouchableOpacity>
</>
) : null}
{/* iOS Context Menu */}
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{contextMenuActions.map((action) => (
<Button
key={action.label}
systemImage={action.icon as any}
onPress={action.onPress}
role={action.role}
label={action.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image
systemName="ellipsis"
color="primary"
size={24}
modifiers={[frame({ height: 24, width: 17 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
</View>
);
};
@@ -0,0 +1,158 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { View, Text, TouchableOpacity, Linking } from "react-native";
import type { BookingListItemProps } from "./types";
import { SvgImage } from "../SvgImage";
import { getMeetingInfo } from "../../utils/meetings-utils";
import { formatTime, formatDate, getHostAndAttendeesDisplay } from "../../utils/bookings-utils";
import { showErrorAlert } from "../../utils/alerts";
export const BookingListItem: React.FC<BookingListItemProps> = ({
booking,
userEmail,
isConfirming,
isDeclining,
onPress,
onLongPress,
onConfirm,
onReject,
onActionsPress,
}) => {
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isUpcoming = new Date(endTime) >= new Date();
const isPending = booking.status?.toUpperCase() === "PENDING";
const isCancelled = booking.status?.toUpperCase() === "CANCELLED";
const isRejected = booking.status?.toUpperCase() === "REJECTED";
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
const meetingInfo = getMeetingInfo(booking.location);
return (
<View className="border-b border-[#E5E5EA] bg-white">
<TouchableOpacity
className="active:bg-[#F8F9FA]"
onPress={() => onPress(booking)}
onLongPress={() => onLongPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
>
{/* Time and Date Row */}
<View className="mb-2 flex-row flex-wrap items-center">
<Text className="text-sm font-medium text-[#333]">
{formatDate(startTime, isUpcoming)}
</Text>
<Text className="ml-2 text-sm text-[#666]">
{formatTime(startTime)} - {formatTime(endTime)}
</Text>
</View>
{/* Badges Row */}
<View className="mb-3 flex-row flex-wrap items-center">
{isPending ? (
<View className="mb-1 mr-2 rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
</View>
) : null}
</View>
{/* Title */}
<Text
className={`mb-2 text-lg font-medium leading-5 text-[#333] ${isCancelled || isRejected ? "line-through" : ""}`}
numberOfLines={2}
>
{booking.title}
</Text>
{/* Description */}
{booking.description ? (
<Text className="mb-2 text-sm leading-5 text-[#666]" numberOfLines={1}>
&quot;{booking.description}&quot;
</Text>
) : null}
{/* Host and Attendees */}
{hostAndAttendeesDisplay ? (
<Text className="mb-2 text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
) : null}
{/* Meeting Link */}
{meetingInfo ? (
<View className="mb-1 flex-row">
<TouchableOpacity
className="flex-row items-center"
style={{ alignSelf: "flex-start" }}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
onPress={async (e) => {
e.stopPropagation();
try {
await Linking.openURL(meetingInfo.cleanUrl);
} catch {
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
}
}}
>
{meetingInfo.iconUrl ? (
<SvgImage
uri={meetingInfo.iconUrl}
width={16}
height={16}
style={{ marginRight: 6 }}
/>
) : (
<Ionicons name="videocam" size={16} color="#007AFF" style={{ marginRight: 6 }} />
)}
<Text className="text-sm font-medium text-[#007AFF]">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
) : null}
</TouchableOpacity>
<View
className="flex-row items-center justify-end"
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
>
{isPending ? (
<>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onConfirm(booking);
}}
>
<Ionicons name="checkmark" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Confirm</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onReject(booking);
}}
>
<Ionicons name="close" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Reject</Text>
</TouchableOpacity>
</>
) : null}
<TouchableOpacity
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={(e) => {
e.stopPropagation();
onActionsPress(booking);
}}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
</View>
</View>
);
};
@@ -0,0 +1,22 @@
import type { Booking } from "../../services/calcom";
export interface BookingListItemProps {
booking: Booking;
userEmail?: string;
isConfirming: boolean;
isDeclining: boolean;
onPress: (booking: Booking) => void;
onLongPress: (booking: Booking) => void;
onConfirm: (booking: Booking) => void;
onReject: (booking: Booking) => void;
onActionsPress: (booking: Booking) => void;
// Additional action handlers for context menu (iOS)
onReschedule?: (booking: Booking) => void;
onEditLocation?: (booking: Booking) => void;
onAddGuests?: (booking: Booking) => void;
onViewRecordings?: (booking: Booking) => void;
onMeetingSessionDetails?: (booking: Booking) => void;
onMarkNoShow?: (booking: Booking) => void;
onReportBooking?: (booking: Booking) => void;
onCancelBooking?: (booking: Booking) => void;
}
@@ -0,0 +1,406 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { Activity, useMemo, useState } from "react";
import { View, Text, FlatList, RefreshControl, ScrollView, Alert } from "react-native";
import type { Booking, EventType } from "../../services/calcom";
import { LoadingSpinner } from "../LoadingSpinner";
import { EmptyScreen } from "../EmptyScreen";
import { BookingListItem } from "../booking-list-item/BookingListItem";
import { BookingModals } from "../booking-modals/BookingModals";
import { useAuth } from "../../contexts/AuthContext";
import {
useBookings,
useCancelBooking,
useConfirmBooking,
useDeclineBooking,
useRescheduleBooking,
useBookingActions,
type BookingFilter,
} from "../../hooks";
import { offlineAwareRefresh } from "../../utils/network";
import {
getEmptyStateContent,
groupBookingsByMonth,
searchBookings,
filterByEventType,
} from "../../utils/bookings-utils";
import type { ListItem } from "../../utils/bookings-utils";
interface BookingListScreenProps {
// Platform-specific header rendering
renderHeader?: () => React.ReactNode;
// Platform-specific filter controls
renderFilterControls?: () => React.ReactNode;
// Whether to show the filter modal (only for non-iOS)
showFilterModal?: boolean;
setShowFilterModal?: (show: boolean) => void;
// Event types for filtering
eventTypes?: EventType[];
eventTypesLoading?: boolean;
// Search query (controlled from parent for iOS headerSearchBar)
searchQuery: string;
onSearchChange?: (query: string) => void;
// Event type filter (controlled from parent)
selectedEventTypeId: number | null;
onEventTypeChange?: (id: number | null, label?: string | null) => void;
// Active filter (controlled from parent)
activeFilter: BookingFilter;
filterParams: Record<string, unknown>;
// iOS-style list (no wrapper)
iosStyle?: boolean;
}
export const BookingListScreen: React.FC<BookingListScreenProps> = ({
renderHeader,
renderFilterControls,
showFilterModal,
setShowFilterModal,
eventTypes,
eventTypesLoading,
searchQuery,
selectedEventTypeId,
onEventTypeChange,
activeFilter,
filterParams,
iosStyle = false,
}) => {
const router = useRouter();
const { userInfo } = useAuth();
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
// Use React Query hook for fetching bookings
const {
data: rawBookings = [],
isLoading: loading,
isFetching,
error: queryError,
refetch,
} = useBookings(filterParams);
// Show refresh indicator when fetching
const refreshing = isFetching && !loading;
// Cancel booking mutation
const { mutate: cancelBookingMutation } = useCancelBooking();
// Confirm booking mutation
const { mutate: confirmBookingMutation, isPending: isConfirming } = useConfirmBooking();
// Decline booking mutation
const { mutate: declineBookingMutation, isPending: isDeclining } = useDeclineBooking();
// Reschedule booking mutation
const { mutate: rescheduleBookingMutation, isPending: isRescheduling } = useRescheduleBooking();
// Booking actions hook
const {
showRescheduleModal,
rescheduleBooking,
rescheduleDate,
setRescheduleDate,
rescheduleTime,
setRescheduleTime,
rescheduleReason,
setRescheduleReason,
showRejectModal,
rejectReason,
setRejectReason,
selectedBooking,
setSelectedBooking,
handleBookingPress,
handleRescheduleBooking,
handleSubmitReschedule,
handleCloseRescheduleModal,
handleCancelBooking,
handleInlineConfirm,
handleOpenRejectModal,
handleSubmitReject,
handleCloseRejectModal,
} = useBookingActions({
router,
cancelMutation: cancelBookingMutation,
confirmMutation: confirmBookingMutation,
declineMutation: declineBookingMutation,
rescheduleMutation: rescheduleBookingMutation,
isConfirming,
isDeclining,
isRescheduling,
});
// Sort bookings based on active filter
const bookings = useMemo(() => {
if (!rawBookings || !Array.isArray(rawBookings)) return [];
const sorted = [...rawBookings];
switch (activeFilter) {
case "upcoming":
case "unconfirmed":
// Sort by start time ascending
return sorted.sort(
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
case "past":
case "cancelled":
// Sort by start time descending (latest first)
return sorted.sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
);
default:
return sorted;
}
}, [rawBookings, activeFilter]);
// Convert query error to string
// Don't show error UI for authentication errors (user will be redirected to login)
// Only show error UI in development mode for other errors
const isAuthError =
queryError?.message?.includes("Authentication") ||
queryError?.message?.includes("sign in") ||
queryError?.message?.includes("401");
const error = queryError && !isAuthError && __DEV__ ? "Failed to load bookings." : null;
// Prevents showing loading indicator when refreshing or changing filters
function manualRefresh() {
setIsManualRefreshing(true);
offlineAwareRefresh(refetch).finally(() => {
setIsManualRefreshing(false);
});
}
// Apply local filters (search and event type) using useMemo
const filteredBookings = useMemo(() => {
let filtered = bookings;
// Apply event type filter
filtered = filterByEventType(filtered, selectedEventTypeId);
// Apply search filter
filtered = searchBookings(filtered, searchQuery);
return filtered;
}, [bookings, searchQuery, selectedEventTypeId]);
const [showBookingActionsModal, setShowBookingActionsModal] = React.useState(false);
const renderBookingItem = ({ item }: { item: Booking }) => {
return (
<BookingListItem
booking={item}
userEmail={userInfo?.email}
isConfirming={isConfirming}
isDeclining={isDeclining}
onPress={handleBookingPress}
onLongPress={(booking) => {
setSelectedBooking(booking);
setShowBookingActionsModal(true);
}}
onConfirm={handleInlineConfirm}
onReject={handleOpenRejectModal}
onActionsPress={(booking) => {
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");
}}
onReportBooking={() => {
Alert.alert("Report Booking", "Report booking functionality coming soon");
}}
onCancelBooking={handleCancelBooking}
/>
);
};
const renderListItem = ({ item }: { item: ListItem }) => {
if (item.type === "monthHeader") {
return (
<View className="border-b border-[#E5E5EA] bg-[#f1f1f1] px-2 py-3 md:px-4">
<Text className="text-base font-bold text-[#333]">{item.monthYear}</Text>
</View>
);
}
return renderBookingItem({ item: item.booking });
};
if (loading) {
return (
<View className="flex-1 bg-gray-50">
{renderHeader?.()}
{renderFilterControls?.()}
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<LoadingSpinner size="large" />
</View>
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-gray-50">
{renderHeader?.()}
{renderFilterControls?.()}
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
Unable to load bookings
</Text>
<Text className="mb-6 text-center text-base text-gray-500">{error}</Text>
</View>
</View>
);
}
// Determine what content to show
const showEmptyState = bookings.length === 0 && !loading;
const showSearchEmptyState =
filteredBookings.length === 0 && searchQuery.trim() !== "" && !loading && !showEmptyState;
const showList = !showEmptyState && !showSearchEmptyState && !loading;
const emptyState = getEmptyStateContent(activeFilter);
return (
<>
{renderHeader?.()}
{renderFilterControls?.()}
{/* Empty state - no bookings */}
<Activity mode={showEmptyState ? "visible" : "hidden"}>
<View className="flex-1 bg-gray-50">
<ScrollView
contentContainerStyle={{
padding: 20,
}}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: "white" }}
>
<EmptyScreen
icon={emptyState.icon}
headline={emptyState.title}
description={emptyState.text}
/>
</ScrollView>
</View>
</Activity>
{/* Search empty state */}
<Activity mode={showSearchEmptyState ? "visible" : "hidden"}>
<View className="flex-1 bg-gray-50">
<ScrollView
contentContainerStyle={{
padding: 20,
}}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: "white" }}
>
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</ScrollView>
</View>
</Activity>
{/* Bookings list */}
<Activity mode={showList ? "visible" : "hidden"}>
<Activity mode={iosStyle ? "visible" : "hidden"}>
<FlatList
data={groupBookingsByMonth(filteredBookings)}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: "white" }}
/>
</Activity>
<Activity mode={!iosStyle ? "visible" : "hidden"}>
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<FlatList
data={groupBookingsByMonth(filteredBookings)}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
</Activity>
</Activity>
{/* Modals */}
<BookingModals
showRescheduleModal={showRescheduleModal}
rescheduleBooking={rescheduleBooking}
rescheduleDate={rescheduleDate}
rescheduleTime={rescheduleTime}
rescheduleReason={rescheduleReason}
isRescheduling={isRescheduling}
onRescheduleClose={handleCloseRescheduleModal}
onRescheduleSubmit={handleSubmitReschedule}
onRescheduleDateChange={setRescheduleDate}
onRescheduleTimeChange={setRescheduleTime}
onRescheduleReasonChange={setRescheduleReason}
showRejectModal={showRejectModal}
rejectReason={rejectReason}
isDeclining={isDeclining}
onRejectClose={handleCloseRejectModal}
onRejectSubmit={handleSubmitReject}
onRejectReasonChange={setRejectReason}
showFilterModal={showFilterModal}
eventTypes={eventTypes}
eventTypesLoading={eventTypesLoading}
selectedEventTypeId={selectedEventTypeId}
onFilterClose={() => setShowFilterModal?.(false)}
onEventTypeSelect={onEventTypeChange}
showBookingActionsModal={showBookingActionsModal}
selectedBooking={selectedBooking}
onActionsClose={() => setShowBookingActionsModal(false)}
onReschedule={() => {
if (selectedBooking) handleRescheduleBooking(selectedBooking);
}}
onCancel={() => {
if (selectedBooking) handleCancelBooking(selectedBooking);
}}
/>
</>
);
};
@@ -0,0 +1,347 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import {
View,
Text,
TouchableOpacity,
TextInput,
ActivityIndicator,
ScrollView,
Alert,
} from "react-native";
import type { Booking, EventType } from "../../services/calcom";
import { FullScreenModal } from "../FullScreenModal";
import { BookingActionsModal } from "../BookingActionsModal";
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;
// Reject modal props
showRejectModal: boolean;
rejectReason: string;
isDeclining: boolean;
onRejectClose: () => void;
onRejectSubmit: () => void;
onRejectReasonChange: (reason: string) => void;
// Filter modal props (optional for iOS)
showFilterModal?: boolean;
eventTypes?: EventType[];
eventTypesLoading?: boolean;
selectedEventTypeId?: number | null;
onFilterClose?: () => void;
onEventTypeSelect?: (id: number | null, label?: string | null) => void;
// Booking actions modal props
showBookingActionsModal: boolean;
selectedBooking: Booking | null;
onActionsClose: () => void;
onReschedule: () => void;
onCancel: () => void;
}
export const BookingModals: React.FC<BookingModalsProps> = ({
showRescheduleModal,
rescheduleBooking,
rescheduleDate,
rescheduleTime,
rescheduleReason,
isRescheduling,
onRescheduleClose,
onRescheduleSubmit,
onRescheduleDateChange,
onRescheduleTimeChange,
onRescheduleReasonChange,
showRejectModal,
rejectReason,
isDeclining,
onRejectClose,
onRejectSubmit,
onRejectReasonChange,
showFilterModal,
eventTypes,
eventTypesLoading,
selectedEventTypeId,
onFilterClose,
onEventTypeSelect,
showBookingActionsModal,
selectedBooking,
onActionsClose,
onReschedule,
onCancel,
}) => {
return (
<>
{/* Filter Modal - Only rendered if props are provided (non-iOS) */}
{showFilterModal !== undefined && onFilterClose && onEventTypeSelect ? (
<FullScreenModal
visible={showFilterModal}
animationType="fade"
onRequestClose={onFilterClose}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-[rgba(0,0,0,0.5)]"
activeOpacity={1}
onPress={onFilterClose}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
className="w-[85%] max-w-[350px] rounded-2xl bg-white p-5"
>
<Text className="mb-4 text-center text-lg font-semibold text-[#333]">
Filter by Event Type
</Text>
{eventTypesLoading ? (
<View className="items-center py-4">
<ActivityIndicator size="small" color="#333" />
<Text className="mt-2 text-sm text-[#666]">Loading event types...</Text>
</View>
) : (
<ScrollView showsVerticalScrollIndicator={true} style={{ maxHeight: 400 }}>
{eventTypes?.map((eventType) => (
<TouchableOpacity
key={eventType.id}
className={`mb-1 flex-row items-center justify-between rounded-lg px-4 py-3 ${
selectedEventTypeId === eventType.id ? "bg-[#F0F0F0]" : ""
}`}
onPress={() => onEventTypeSelect(eventType.id, eventType.title)}
>
<Text
className={`text-base text-[#333] ${selectedEventTypeId === eventType.id ? "font-semibold" : ""}`}
>
{eventType.title}
</Text>
{selectedEventTypeId === eventType.id ? (
<Ionicons name="checkmark" size={20} color="#000" />
) : null}
</TouchableOpacity>
))}
{eventTypes?.length === 0 ? (
<View className="items-center py-4">
<Text className="text-sm text-[#666]">No event types found</Text>
</View>
) : null}
</ScrollView>
)}
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
) : null}
{/* Booking Actions Modal */}
<BookingActionsModal
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"}
onReschedule={onReschedule}
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");
}}
onReportBooking={() => {
Alert.alert("Report Booking", "Report booking functionality coming soon");
}}
onCancelBooking={onCancel}
/>
{/* Reschedule Modal */}
<FullScreenModal visible={showRescheduleModal} onRequestClose={onRescheduleClose}>
<ScrollView className="flex-1 p-4">
{rescheduleBooking ? (
<>
<Text className="mb-4 text-base text-gray-600">
Reschedule "{rescheduleBooking.title}"
</Text>
{/* Date Input */}
<View className="mb-4">
<Text className="mb-2 text-sm font-medium text-gray-700">
New Date (YYYY-MM-DD)
</Text>
<TextInput
className="rounded-lg border border-gray-300 bg-white px-4 py-3 text-base"
value={rescheduleDate}
onChangeText={onRescheduleDateChange}
placeholder="2024-12-25"
placeholderTextColor="#9CA3AF"
keyboardType="default"
/>
</View>
{/* Time Input */}
<View className="mb-4">
<Text className="mb-2 text-sm font-medium text-gray-700">
New Time (HH:MM, 24-hour format)
</Text>
<TextInput
className="rounded-lg border border-gray-300 bg-white px-4 py-3 text-base"
value={rescheduleTime}
onChangeText={onRescheduleTimeChange}
placeholder="14:30"
placeholderTextColor="#9CA3AF"
keyboardType="default"
/>
</View>
{/* Reason Input */}
<View className="mb-6">
<Text className="mb-2 text-sm font-medium text-gray-700">Reason (optional)</Text>
<TextInput
className="rounded-lg border border-gray-300 bg-white px-4 py-3 text-base"
value={rescheduleReason}
onChangeText={onRescheduleReasonChange}
placeholder="Enter reason for rescheduling..."
placeholderTextColor="#9CA3AF"
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
{/* Submit Button */}
<TouchableOpacity
className={`rounded-lg p-4 ${isRescheduling ? "bg-gray-400" : "bg-black"}`}
onPress={onRescheduleSubmit}
disabled={isRescheduling}
>
{isRescheduling ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-center text-base font-semibold text-white">
Reschedule Booking
</Text>
)}
</TouchableOpacity>
{/* Cancel Button */}
<TouchableOpacity
className="mt-3 rounded-lg bg-gray-100 p-4"
onPress={onRescheduleClose}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</>
) : null}
</ScrollView>
</FullScreenModal>
{/* Reject Booking Modal */}
<FullScreenModal
visible={showRejectModal}
animationType="fade"
onRequestClose={onRejectClose}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-4"
activeOpacity={1}
onPress={onRejectClose}
>
<TouchableOpacity
className="w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="p-5">
{/* Title */}
<Text className="mb-1 text-lg font-semibold text-gray-900">
Reject the booking request?
</Text>
{/* Description */}
<Text className="mb-4 text-sm text-gray-500">
Are you sure you want to reject the booking? We'll let the person who tried to book
know. You can provide a reason below.
</Text>
{/* Reason Input */}
<View className="mb-5">
<Text className="mb-1.5 text-sm text-gray-700">
Reason for rejecting <Text className="font-normal text-gray-400">(Optional)</Text>
</Text>
<TextInput
className="rounded-md border border-gray-300 bg-white px-3 py-2.5 text-sm"
value={rejectReason}
onChangeText={onRejectReasonChange}
placeholder="Enter reason..."
placeholderTextColor="#9CA3AF"
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 70 }}
/>
</View>
{/* Separator */}
<View className="mb-4 h-px bg-gray-200" />
{/* Buttons Row */}
<View className="flex-row justify-end" style={{ gap: 8 }}>
{/* Close Button */}
<TouchableOpacity
className="rounded-md border border-gray-300 bg-white px-4 py-2"
onPress={onRejectClose}
>
<Text className="text-sm font-medium text-gray-700">Close</Text>
</TouchableOpacity>
{/* Reject Button */}
<TouchableOpacity
className="rounded-md bg-gray-900 px-4 py-2"
onPress={onRejectSubmit}
disabled={isDeclining}
style={{ opacity: isDeclining ? 0.5 : 1 }}
>
<Text className="text-sm font-medium text-white">Reject the booking</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
</>
);
};
+9
View File
@@ -65,6 +65,15 @@ export {
type UpdateUserProfileInput,
} from "./useUserProfile";
// UI State Management hooks
export {
useActiveBookingFilter,
type BookingFilter,
type BookingFilterOption,
} from "./useActiveBookingFilter";
export { useBookingActions } from "./useBookingActions";
// Re-export query keys for advanced use cases
export { queryKeys } from "../config/cache.config";
+135
View File
@@ -0,0 +1,135 @@
import { useState, useMemo, useCallback } from "react";
import type { NativeSyntheticEvent } from "react-native";
import type { BookingFilters } from "./useBookings";
export type BookingFilter = "upcoming" | "unconfirmed" | "past" | "cancelled";
export interface BookingFilterOption {
key: BookingFilter;
label: string;
}
const FILTER_OPTIONS: BookingFilterOption[] = [
{ key: "upcoming", label: "Upcoming" },
{ key: "unconfirmed", label: "Unconfirmed" },
{ key: "past", label: "Past" },
{ key: "cancelled", label: "Cancelled" },
];
/**
* Hook to manage active booking filter state and provide filter-related utilities.
*
* Features:
* - Manages active filter state
* - Provides filter options and labels for UI components
* - Converts filter selection to API-compatible parameters
* - Handles segmented control integration
* - Supports change callbacks for dependent filter cleanup
*
* @param initialFilter - Initial filter value (defaults to "upcoming")
* @param onFilterChange - Optional callback invoked when filter changes
* @returns Object containing filter state, handlers, and computed values
*
* @example
* ```tsx
* const {
* activeFilter,
* filterLabels,
* activeIndex,
* filterParams,
* handleFilterChange,
* handleSegmentChange,
* } = useActiveBookingFilter("upcoming", () => {
* // Clear dependent filters like search query, event type filter, etc.
* setSearchQuery("");
* setSelectedEventTypeId(null);
* });
*
* // Use with React Query
* const { data: bookings } = useBookings(filterParams);
*
* // Use with SegmentedControl
* <SegmentedControl
* values={filterLabels}
* selectedIndex={activeIndex}
* onChange={handleSegmentChange}
* />
* ```
*/
export function useActiveBookingFilter(
initialFilter: BookingFilter = "upcoming",
onFilterChange?: (filter: BookingFilter) => void
) {
const [activeFilter, setActiveFilter] = useState<BookingFilter>(initialFilter);
/**
* Converts the active filter to API-compatible query parameters
*/
const filterParams = useMemo<BookingFilters>(() => {
switch (activeFilter) {
case "upcoming":
return { status: ["upcoming"], limit: 50 };
case "unconfirmed":
return { status: ["unconfirmed"], limit: 50 };
case "past":
return { status: ["past"], limit: 100 };
case "cancelled":
return { status: ["cancelled"], limit: 100 };
default:
return { status: ["upcoming"], limit: 50 };
}
}, [activeFilter]);
/**
* Array of filter labels for UI components (e.g., SegmentedControl)
*/
const filterLabels = useMemo(() => FILTER_OPTIONS.map((option) => option.label), []);
/**
* Current active filter index for UI components
*/
const activeIndex = useMemo(
() => FILTER_OPTIONS.findIndex((option) => option.key === activeFilter),
[activeFilter]
);
/**
* Changes the active filter and invokes the optional callback
*/
const handleFilterChange = useCallback(
(filter: BookingFilter) => {
setActiveFilter(filter);
onFilterChange?.(filter);
},
[onFilterChange]
);
/**
* Handler for React Native SegmentedControl onChange event
*/
const handleSegmentChange = useCallback(
(event: NativeSyntheticEvent<{ selectedSegmentIndex: number }>) => {
const { selectedSegmentIndex } = event.nativeEvent;
const selectedFilter = FILTER_OPTIONS[selectedSegmentIndex];
if (selectedFilter) {
handleFilterChange(selectedFilter.key);
}
},
[handleFilterChange]
);
return {
// State
activeFilter,
// Computed values
filterOptions: FILTER_OPTIONS,
filterLabels,
activeIndex,
filterParams,
// Handlers
handleFilterChange,
handleSegmentChange,
};
}
+361
View File
@@ -0,0 +1,361 @@
import { useState } from "react";
import { Alert } from "react-native";
import type { useRouter } from "expo-router";
import type { Booking } from "../services/calcom";
import { showErrorAlert } from "../utils/alerts";
interface UseBookingActionsParams {
router: ReturnType<typeof useRouter>;
cancelMutation: (
params: { uid: string; reason: string },
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
}
) => void;
confirmMutation: (
params: { uid: string },
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
}
) => void;
declineMutation: (
params: { uid: string; reason?: string },
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
}
) => void;
rescheduleMutation: (
params: { uid: string; start: string; reschedulingReason?: string },
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
}
) => void;
isConfirming: boolean;
isDeclining: boolean;
isRescheduling: boolean;
}
export const useBookingActions = ({
router,
cancelMutation,
confirmMutation,
declineMutation,
rescheduleMutation,
isConfirming,
isDeclining,
isRescheduling,
}: UseBookingActionsParams) => {
// Reschedule modal state
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
const [rescheduleBooking, setRescheduleBooking] = useState<Booking | null>(null);
const [rescheduleDate, setRescheduleDate] = useState("");
const [rescheduleTime, setRescheduleTime] = useState("");
const [rescheduleReason, setRescheduleReason] = useState("");
// Reject modal state
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectBooking, setRejectBooking] = useState<Booking | null>(null);
const [rejectReason, setRejectReason] = useState("");
// Selected booking for actions modal
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
/**
* Navigate to booking detail page
*/
const handleBookingPress = (booking: Booking) => {
router.push({
pathname: "/booking-detail",
params: { uid: booking.uid },
});
};
/**
* 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
setRescheduleBooking(booking);
setRescheduleDate(dateStr);
setRescheduleTime(timeStr);
setRescheduleReason("");
setShowRescheduleModal(true);
};
/**
* Validate and submit reschedule request
*/
const handleSubmitReschedule = () => {
if (!rescheduleBooking || !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();
rescheduleMutation(
{
uid: rescheduleBooking.uid,
start: startUtc,
reschedulingReason: rescheduleReason || undefined,
},
{
onSuccess: () => {
setShowRescheduleModal(false);
setRescheduleBooking(null);
Alert.alert("Success", "Booking rescheduled successfully");
},
onError: (error) => {
showErrorAlert("Error", error.message || "Failed to reschedule booking");
},
}
);
};
/**
* Close reschedule modal and reset state
*/
const handleCloseRescheduleModal = () => {
setShowRescheduleModal(false);
setRescheduleBooking(null);
};
/**
* Show alert and cancel booking (iOS Alert.prompt pattern)
*/
const handleCancelBooking = (booking: Booking) => {
Alert.alert("Cancel Event", `Are you sure you want to cancel "${booking.title}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Cancel Event",
style: "destructive",
onPress: () => {
// Prompt for cancellation reason
Alert.prompt(
"Cancellation Reason",
"Please provide a reason for cancelling this booking:",
[
{ text: "Cancel", style: "cancel" },
{
text: "Cancel Event",
style: "destructive",
onPress: (reason) => {
const cancellationReason = reason?.trim() || "Event cancelled by host";
cancelMutation(
{ uid: booking.uid, reason: cancellationReason },
{
onSuccess: () => {
Alert.alert("Success", "Event cancelled successfully");
},
onError: (error) => {
console.error("Failed to cancel booking:", error);
showErrorAlert("Error", "Failed to cancel event. Please try again.");
},
}
);
},
},
],
"plain-text",
"",
"default"
);
},
},
]);
};
/**
* Show alert and confirm booking
*/
const handleConfirmBooking = (booking: Booking) => {
Alert.alert("Confirm Booking", `Are you sure you want to confirm "${booking.title}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Confirm",
onPress: () => {
confirmMutation(
{ uid: booking.uid },
{
onSuccess: () => {
Alert.alert("Success", "Booking confirmed successfully");
},
onError: (error) => {
showErrorAlert("Error", error.message || "Failed to confirm booking");
},
}
);
},
},
]);
};
/**
* Open reject modal (used by iOS for custom modal UI)
*/
const handleOpenRejectModal = (booking: Booking) => {
setRejectBooking(booking);
setRejectReason("");
setShowRejectModal(true);
};
/**
* Show alert and reject booking (Android Alert.prompt pattern)
*/
const handleRejectBooking = (booking: Booking) => {
Alert.alert("Decline Booking", `Are you sure you want to decline "${booking.title}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Decline",
style: "destructive",
onPress: () => {
// Show optional reason input
Alert.prompt(
"Decline Reason",
"Optionally provide a reason for declining (press OK to skip)",
[
{ text: "Cancel", style: "cancel" },
{
text: "OK",
onPress: (reason?: string) => {
declineMutation(
{ uid: booking.uid, reason: reason || undefined },
{
onSuccess: () => {
Alert.alert("Success", "Booking declined successfully");
},
onError: (error) => {
showErrorAlert("Error", error.message || "Failed to decline booking");
},
}
);
},
},
],
"plain-text",
"",
"default"
);
},
},
]);
};
/**
* Submit rejection with reason (used by iOS custom modal)
*/
const handleSubmitReject = () => {
if (!rejectBooking) return;
declineMutation(
{ uid: rejectBooking.uid, reason: rejectReason || undefined },
{
onSuccess: () => {
setShowRejectModal(false);
setRejectBooking(null);
setRejectReason("");
Alert.alert("Success", "Booking rejected successfully");
},
onError: (error) => {
showErrorAlert("Error", "Failed to reject booking. Please try again.");
},
}
);
};
/**
* Close reject modal and reset state
*/
const handleCloseRejectModal = () => {
setShowRejectModal(false);
setRejectBooking(null);
setRejectReason("");
};
/**
* Inline confirm handler (for use directly in JSX)
*/
const handleInlineConfirm = (booking: Booking) => {
confirmMutation(
{ uid: booking.uid },
{
onSuccess: () => {
Alert.alert("Success", "Booking confirmed successfully");
},
onError: (error) => {
showErrorAlert("Error", "Failed to confirm booking. Please try again.");
},
}
);
};
return {
// Reschedule state
showRescheduleModal,
setShowRescheduleModal,
rescheduleBooking,
rescheduleDate,
setRescheduleDate,
rescheduleTime,
setRescheduleTime,
rescheduleReason,
setRescheduleReason,
// Reject state
showRejectModal,
setShowRejectModal,
rejectBooking,
rejectReason,
setRejectReason,
// Selected booking state
selectedBooking,
setSelectedBooking,
// Handlers
handleBookingPress,
handleRescheduleBooking,
handleSubmitReschedule,
handleCloseRescheduleModal,
handleCancelBooking,
handleConfirmBooking,
handleOpenRejectModal,
handleRejectBooking,
handleSubmitReject,
handleCloseRejectModal,
handleInlineConfirm,
// Loading states (pass through)
isConfirming,
isDeclining,
isRescheduling,
};
};
+359
View File
@@ -0,0 +1,359 @@
import type { BookingFilter } from "../hooks";
import type { Booking } from "../services/calcom";
export const getEmptyStateContent = (activeFilter: BookingFilter) => {
switch (activeFilter) {
case "upcoming":
return {
icon: "calendar-outline" as const,
title: "No upcoming bookings",
text: "As soon as someone books a time with you it will show up here.",
};
case "unconfirmed":
return {
icon: "calendar-outline" as const,
title: "No unconfirmed bookings",
text: "Your unconfirmed bookings will show up here.",
};
case "past":
return {
icon: "calendar-outline" as const,
title: "No past bookings",
text: "Your past bookings will show up here.",
};
case "cancelled":
return {
icon: "calendar-outline" as const,
title: "No cancelled bookings",
text: "Your canceled bookings will show up here.",
};
default:
return {
icon: "calendar-outline" as const,
title: "No bookings found",
text: "Your bookings will appear here.",
};
}
};
/**
* Format a date string to a time string in 12-hour format
* @param dateString ISO date string
* @returns Formatted time string (e.g., "2:30 PM")
*/
export const formatTime = (dateString: string): string => {
if (!dateString) {
return "";
}
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
console.warn("Invalid date string:", dateString);
return "";
}
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
} catch (error) {
console.error("Error formatting time:", error, dateString);
return "";
}
};
/**
* Format a date string to a human-readable date
* @param dateString ISO date string
* @param isUpcoming Whether the booking is upcoming (affects format)
* @returns Formatted date string
*/
export const formatDate = (dateString: string, isUpcoming: boolean): string => {
if (!dateString) {
return "";
}
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
console.warn("Invalid date string:", dateString);
return "";
}
const bookingYear = date.getFullYear();
const currentYear = new Date().getFullYear();
const isDifferentYear = bookingYear !== currentYear;
if (isUpcoming) {
// Format: "ddd, D MMM" or "ddd, D MMM YYYY" if different year
const weekday = date.toLocaleDateString("en-US", { weekday: "short" });
const day = date.getDate();
const month = date.toLocaleDateString("en-US", { month: "short" });
if (isDifferentYear) {
return `${weekday}, ${day} ${month} ${bookingYear}`;
}
return `${weekday}, ${day} ${month}`;
} else {
// Format: "D MMMM YYYY" for past bookings
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
});
}
} catch (error) {
console.error("Error formatting date:", error, dateString);
return "";
}
};
/**
* Format a date string to month and year
* @param dateString ISO date string
* @returns Formatted month and year string (e.g., "December 2024" or "This month")
*/
export const formatMonthYear = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "";
}
const now = new Date();
const isCurrentMonth =
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
if (isCurrentMonth) {
return "This month";
}
return date.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
} catch (error) {
return "";
}
};
/**
* Get a unique key for a month and year from a date string
* @param dateString ISO date string
* @returns Key in format "YYYY-M" (e.g., "2024-11")
*/
export const getMonthYearKey = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "";
}
return `${date.getFullYear()}-${date.getMonth()}`;
} catch (error) {
return "";
}
};
/**
* Type definition for list items (used in FlatList)
*/
export type ListItem =
| { type: "monthHeader"; monthYear: string; key: string }
| { type: "booking"; booking: Booking; key: string };
/**
* Group bookings by month for display in a sectioned list
* @param bookings Array of bookings to group
* @returns Array of list items with month headers and bookings
*/
export const groupBookingsByMonth = (bookings: Booking[]): ListItem[] => {
const grouped: ListItem[] = [];
let currentMonthYear: string | null = null;
bookings.forEach((booking) => {
const startTime = booking.start || booking.startTime || "";
if (!startTime) return;
const monthYearKey = getMonthYearKey(startTime);
const monthYear = formatMonthYear(startTime);
if (monthYearKey !== currentMonthYear) {
currentMonthYear = monthYearKey;
grouped.push({
type: "monthHeader",
monthYear,
key: `month-${monthYearKey}`,
});
}
grouped.push({
type: "booking",
booking,
key: booking.id.toString(),
});
});
return grouped;
};
/**
* Search/filter bookings by query string
* @param bookings Array of bookings to filter
* @param searchQuery Search query string
* @returns Filtered bookings
*/
export const searchBookings = (bookings: Booking[], searchQuery: string): Booking[] => {
if (searchQuery.trim() === "") {
return bookings;
}
const searchLower = searchQuery.toLowerCase();
return bookings.filter(
(booking) =>
// Search in booking title
booking.title?.toLowerCase().includes(searchLower) ||
// Search in booking description
booking.description?.toLowerCase().includes(searchLower) ||
// Search in event type title
booking.eventType?.title?.toLowerCase().includes(searchLower) ||
// Search in attendee names
(booking.attendees &&
booking.attendees.some((attendee) => attendee.name?.toLowerCase().includes(searchLower))) ||
// Search in attendee emails
(booking.attendees &&
booking.attendees.some((attendee) =>
attendee.email?.toLowerCase().includes(searchLower)
)) ||
// Search in location
booking.location?.toLowerCase().includes(searchLower) ||
// Search in user name
booking.user?.name?.toLowerCase().includes(searchLower) ||
// Search in user email
booking.user?.email?.toLowerCase().includes(searchLower)
);
};
/**
* Filter bookings by event type ID
* @param bookings Array of bookings to filter
* @param eventTypeId Event type ID to filter by (null means no filter)
* @returns Filtered bookings
*/
export const filterByEventType = (bookings: Booking[], eventTypeId: number | null): Booking[] => {
if (eventTypeId === null) {
return bookings;
}
return bookings.filter((booking) => booking.eventTypeId === eventTypeId);
};
/**
* Format a date string to a complete date/time string
* @param dateString ISO date string
* @returns Formatted date/time string (e.g., "Mon, Dec 25, 2:30 PM")
*/
export const formatDateTime = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
/**
* Get color for booking status
* @param status Booking status string
* @returns Hex color code
*/
export const getStatusColor = (status: string): string => {
// API v2 2024-08-13 returns status in lowercase, so normalize to uppercase for comparison
const normalizedStatus = status.toUpperCase();
switch (normalizedStatus) {
case "ACCEPTED":
return "#34C759";
case "PENDING":
return "#FF9500";
case "CANCELLED":
return "#FF3B30";
case "REJECTED":
return "#FF3B30";
default:
return "#666";
}
};
/**
* Format status text for display
* @param status Booking status string
* @returns Capitalized status text
*/
export const formatStatusText = (status: string): string => {
// Capitalize first letter for display
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
/**
* Get formatted display string for host and attendees
* @param booking Booking object
* @param currentUserEmail Current user's email (optional)
* @returns Formatted string or null if no host/attendees
*/
export const getHostAndAttendeesDisplay = (
booking: Booking,
currentUserEmail?: string
): string | null => {
const hasHostOrAttendees =
(booking.hosts && booking.hosts.length > 0) ||
booking.user ||
(booking.attendees && booking.attendees.length > 0);
if (!hasHostOrAttendees) return null;
const hostEmail = booking.hosts?.[0]?.email?.toLowerCase() || booking.user?.email?.toLowerCase();
const isCurrentUserHost =
currentUserEmail && hostEmail && currentUserEmail.toLowerCase() === hostEmail;
const hostName = isCurrentUserHost
? "You"
: booking.hosts?.[0]?.name ||
booking.hosts?.[0]?.email ||
booking.user?.name ||
booking.user?.email;
const attendeesDisplay =
booking.attendees && booking.attendees.length > 0
? booking.attendees.length === 1
? booking.attendees[0].name || booking.attendees[0].email
: booking.attendees
.slice(0, 2)
.map((att) => att.name || att.email)
.join(", ") +
(booking.attendees.length > 2 ? ` and ${booking.attendees.length - 2} more` : "")
: null;
if (hostName && attendeesDisplay) {
return `${hostName} and ${attendeesDisplay}`;
} else if (hostName) {
return hostName;
} else if (attendeesDisplay) {
return attendeesDisplay;
}
return null;
};
/**
* Get shareable booking details message
* @param booking Booking object
* @returns Formatted booking details string
*/
export const getBookingDetailsMessage = (booking: Booking): string => {
const attendeesList = booking.attendees?.map((att) => att.name).join(", ") || "No attendees";
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
return `${booking.description ? `${booking.description}\n\n` : ""}Time: ${formatDateTime(
startTime
)} - ${formatTime(endTime)}\nAttendees: ${attendeesList}\nStatus: ${formatStatusText(booking.status)}${
booking.location ? `\nLocation: ${booking.location}` : ""
}`;
};
+93
View File
@@ -0,0 +1,93 @@
import { getAppIconUrl } from "./getAppIconUrl";
// Helper to extract clean meeting URL from potentially wrapped URLs
function extractMeetingUrl(location: string): string {
// Check if it's a goo.gl redirect URL with embedded meet.google.com link
if (location.includes("goo.gl") && location.includes("meet.google.com")) {
// Extract the actual meet.google.com URL from the redirect
const meetMatch = location.match(/meet\.google\.com\/[a-z]+-[a-z]+-[a-z]+/i);
if (meetMatch) {
return `https://${meetMatch[0]}`;
}
}
return location;
}
// Helper to detect meeting type from location URL and get icon/label
export const getMeetingInfo = (
location?: string
): { appId: string; label: string; iconUrl: string | null; cleanUrl: string } | null => {
if (!location) return null;
// Check if it's a URL
if (!location.match(/^https?:\/\//)) return null;
const lowerLocation = location.toLowerCase();
const cleanUrl = extractMeetingUrl(location);
// Cal Video
if (lowerLocation.includes("cal.com/video") || lowerLocation.includes("cal.video")) {
return {
appId: "cal-video",
label: "Join Cal Video",
iconUrl: getAppIconUrl("daily_video", "cal-video"),
cleanUrl,
};
}
// Google Meet (including goo.gl redirect URLs)
if (
lowerLocation.includes("meet.google.com") ||
(lowerLocation.includes("goo.gl") && lowerLocation.includes("meet"))
) {
return {
appId: "google-meet",
label: "Join Google Meet",
iconUrl: getAppIconUrl("google_video", "google-meet"),
cleanUrl,
};
}
// Zoom
if (lowerLocation.includes("zoom.us") || lowerLocation.includes("zoom.com")) {
return {
appId: "zoom",
label: "Join Zoom",
iconUrl: getAppIconUrl("zoom_video", "zoom"),
cleanUrl,
};
}
// Microsoft Teams
if (lowerLocation.includes("teams.microsoft.com") || lowerLocation.includes("teams.live.com")) {
return {
appId: "msteams",
label: "Join Microsoft Teams",
iconUrl: getAppIconUrl("office365_video", "msteams"),
cleanUrl,
};
}
// Webex
if (lowerLocation.includes("webex.com")) {
return {
appId: "webex",
label: "Join Webex",
iconUrl: getAppIconUrl("webex_video", "webex"),
cleanUrl,
};
}
// Jitsi
if (lowerLocation.includes("meet.jit.si") || lowerLocation.includes("jitsi")) {
return {
appId: "jitsi",
label: "Join Jitsi",
iconUrl: getAppIconUrl("jitsi_video", "jitsi"),
cleanUrl,
};
}
// Not a recognized conferencing app
return null;
};