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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
"{booking.description}"
|
||||
</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}>
|
||||
"{booking.description}"
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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}` : ""
|
||||
}`;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user