feat(companion): UI Enhancements for Android and Extension (#26434)

* feat: companion-android-ui-upgrade version 1

* recurrings and unconfirmed booking filter and page implementation

* add badge and links to event type list page

* address cubics comments

* feat(companion): unify dropdown menu for Android and extension (#26486)

* feat(companion): unify dropdown menu for Android and extension

- Merge Android-specific dropdown implementations into base component files
- EventTypeListItem: Add DropdownMenu with Preview, Copy link, Edit, Duplicate, Delete actions
- BookingListItem: Add DropdownMenu with booking actions (reschedule, edit location, add guests, etc.)
- RecurringBookingListItem: Add DropdownMenu with recurring booking actions
- AvailabilityListItem: Add DropdownMenu with Set as Default, Duplicate, Delete actions
- BookingDetailScreen: Add DropdownMenu in header for Android with AlertDialog for cancel confirmation
- Delete all .android.tsx files as implementations are now unified

* fix(companion): fix typecheck errors after dropdown unification

- Remove unused props from EventTypeListItem.ios.tsx (copiedEventTypeId, handleEventTypeLongPress)
- Remove unused onActionsPress prop from BookingListItem.ios.tsx
- Remove copiedEventTypeId and handleEventTypeLongPress props from index.ios.tsx and index.tsx
- Remove onLongPress and onActionsPress props from BookingListScreen.tsx
- Remove handleScheduleLongPress, setSelectedSchedule, setShowActionsModal props from AvailabilityListScreen.tsx
- Fix BookingDetailScreen.tsx to use correct action property names (reschedule.visible instead of canReschedule, etc.)

* fix(companion): remove unused code after dropdown unification

- Remove unused handleEventTypeLongPress function and ActionSheetIOS import from index.ios.tsx
- Remove unused copiedEventTypeId state, handleEventTypeLongPress function, and ActionSheetIOS import from index.tsx
- Prefix unused setSelectedBooking with underscore in BookingListScreen.tsx
- Remove unused handleScheduleLongPress function and ActionSheetIOS import from AvailabilityListScreen.tsx

* feat(companion): unify booking filter UI for Android and extension

- Remove SegmentedControl from web/extension booking list page
- Use Header dropdown for booking status filter on both Android and web
- Use unified event type filter dropdown for both platforms
- Remove unused showFilterModal state and related code
- Pass filterOptions, activeFilter, and onFilterChange to Header for all platforms

* fix(companion): add header padding for web/extension to prevent button clipping

- Add headerLeftContainerStyle and headerRightContainerStyle with 12px padding for web platform
- Applied to root Stack and all nested tab Stack layouts
- Fixes issue where header buttons were touching edges and getting chopped off on extension/web
- Android remains unaffected as the fix is web-only

* fix(companion): add HeaderButtonWrapper for web-only header padding

- Create HeaderButtonWrapper component that adds 12px margin on web only
- Wrap all header buttons with HeaderButtonWrapper to prevent clipping
- Revert invalid screenOptions changes that caused typecheck errors
- Apply fix to all screens with native header buttons:
  - reschedule.tsx, edit-location.tsx, add-guests.tsx
  - mark-no-show.tsx, view-recordings.tsx, meeting-session-details.tsx
  - event-type-detail.tsx, BookingDetailScreen.tsx, profile-sheet.tsx
  - edit-availability-day.tsx, edit-availability-name.tsx, edit-availability-override.tsx

* update more ui-ux

* address cubics comments

* address cubics comments & open event type list page first for inttial render of app
This commit is contained in:
Dhairyashil Shinde
2026-01-07 07:19:07 -03:00
committed by GitHub
parent 6d2491b394
commit 9e84f59216
58 changed files with 3752 additions and 2465 deletions
@@ -94,7 +94,7 @@ export default function Availability() {
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
hidden={Platform.OS === "android"}
hidden={Platform.OS === "android" || Platform.OS === "web"}
>
<Stack.Header.Title large>Availability</Stack.Header.Title>
<Stack.Header.Right>
@@ -1,10 +1,11 @@
import * as Clipboard from "expo-clipboard";
import { Stack, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef } from "react";
import { Alert } from "react-native";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import { useAuth } from "@/contexts/AuthContext";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { useBookingByUid } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { openInAppBrowser } from "@/utils/browser";
@@ -59,10 +60,11 @@ const getMeetingUrl = (booking: Booking | null): string | null => {
};
export default function BookingDetailIOS() {
"use no memo";
const { uid } = useLocalSearchParams<{ uid: string }>();
const { userInfo } = useAuth();
const [booking, setBooking] = useState<Booking | null>(null);
// Use React Query hook for booking data - single source of truth
const { data: booking, isLoading, error, refetch, isRefetching } = useBookingByUid(uid);
// Ref to store action handlers from BookingDetailScreen
const actionHandlersRef = useRef<ActionHandlers | null>(null);
@@ -72,17 +74,6 @@ export default function BookingDetailIOS() {
actionHandlersRef.current = handlers;
}, []);
// Fetch booking data for the iOS header menu actions
useEffect(() => {
if (uid) {
CalComAPIService.getBookingByUid(uid)
.then(setBooking)
.catch(() => {
// Error handling is done in BookingDetailScreen
});
}
}, [uid]);
// Get the month name from booking start date for back button
const monthName = useMemo(() => {
const startTime = booking?.start || booking?.startTime;
@@ -90,7 +81,7 @@ export default function BookingDetailIOS() {
}, [booking?.start, booking?.startTime]);
// Get meeting URL for Join button
const meetingUrl = useMemo(() => getMeetingUrl(booking), [booking]);
const meetingUrl = useMemo(() => getMeetingUrl(booking ?? null), [booking]);
// Handle join meeting
const handleJoinMeeting = useCallback(() => {
@@ -119,89 +110,44 @@ export default function BookingDetailIOS() {
});
}, [booking, userInfo?.id, userInfo?.email]);
// Action handlers that use the booking data
const handleReschedule = useCallback(() => {
if (actionHandlersRef.current?.openRescheduleModal) {
actionHandlersRef.current.openRescheduleModal();
// Invoke a handler by name - only accesses ref at invocation time (event handler)
// This avoids creating closures that capture the ref during render
const invokeHandler = useCallback((handlerName: keyof ActionHandlers, errorMessage: string) => {
const handlers = actionHandlersRef.current;
if (handlers?.[handlerName]) {
(handlers[handlerName] as () => void)();
} else {
Alert.alert("Error", "Unable to reschedule. Please try again.");
Alert.alert("Error", errorMessage);
}
}, []);
const handleEditLocation = useCallback(() => {
if (actionHandlersRef.current?.openEditLocationModal) {
actionHandlersRef.current.openEditLocationModal();
} else {
Alert.alert("Error", "Unable to edit location. Please try again.");
}
}, []);
const handleAddGuests = useCallback(() => {
if (actionHandlersRef.current?.openAddGuestsModal) {
actionHandlersRef.current.openAddGuestsModal();
} else {
Alert.alert("Error", "Unable to add guests. Please try again.");
}
}, []);
const handleViewRecordings = useCallback(() => {
if (actionHandlersRef.current?.openViewRecordingsModal) {
actionHandlersRef.current.openViewRecordingsModal();
} else {
Alert.alert("Error", "Unable to view recordings. Please try again.");
}
}, []);
const handleSessionDetails = useCallback(() => {
if (actionHandlersRef.current?.openMeetingSessionDetailsModal) {
actionHandlersRef.current.openMeetingSessionDetailsModal();
} else {
Alert.alert("Error", "Unable to view session details. Please try again.");
}
}, []);
const handleMarkNoShow = useCallback(() => {
if (actionHandlersRef.current?.openMarkNoShowModal) {
actionHandlersRef.current.openMarkNoShowModal();
} else {
Alert.alert("Error", "Unable to mark no-show. Please try again.");
}
}, []);
const handleReport = useCallback(() => {
Alert.alert("Report Booking", "Report booking functionality is not yet available");
}, []);
const handleCancel = useCallback(() => {
if (actionHandlersRef.current?.handleCancelBooking) {
actionHandlersRef.current.handleCancelBooking();
} else {
Alert.alert("Error", "Unable to cancel. Please try again.");
}
}, []);
// Define booking actions organized by sections
// Define and filter booking actions
// Actions store handler metadata instead of closures to avoid capturing ref during render
const bookingActionsSections = useMemo(() => {
// Define all actions with handler metadata (not closures)
const allEditEventActions = [
{
id: "reschedule",
label: "Reschedule Booking",
icon: "calendar" as const,
onPress: handleReschedule,
handlerName: "openRescheduleModal" as const,
errorMessage: "Unable to reschedule. Please try again.",
gatingKey: "reschedule" as const,
},
{
id: "edit-location",
label: "Edit Location",
icon: "location" as const,
onPress: handleEditLocation,
handlerName: "openEditLocationModal" as const,
errorMessage: "Unable to edit location. Please try again.",
gatingKey: "changeLocation" as const,
},
{
id: "add-guests",
label: "Add Guests",
icon: "person.badge.plus" as const,
onPress: handleAddGuests,
handlerName: "openAddGuestsModal" as const,
errorMessage: "Unable to add guests. Please try again.",
gatingKey: "addGuests" as const,
},
];
@@ -211,21 +157,24 @@ export default function BookingDetailIOS() {
id: "view-recordings",
label: "View Recordings",
icon: "video" as const,
onPress: handleViewRecordings,
handlerName: "openViewRecordingsModal" as const,
errorMessage: "Unable to view recordings. Please try again.",
gatingKey: "viewRecordings" as const,
},
{
id: "session-details",
label: "Meeting Session Details",
icon: "info.circle" as const,
onPress: handleSessionDetails,
handlerName: "openMeetingSessionDetailsModal" as const,
errorMessage: "Unable to view session details. Please try again.",
gatingKey: "meetingSessionDetails" as const,
},
{
id: "mark-no-show",
label: "Mark as No-Show",
icon: "eye.slash" as const,
onPress: handleMarkNoShow,
handlerName: "openMarkNoShowModal" as const,
errorMessage: "Unable to mark no-show. Please try again.",
gatingKey: "markNoShow" as const,
},
];
@@ -235,7 +184,11 @@ export default function BookingDetailIOS() {
id: "report",
label: "Report Booking",
icon: "flag" as const,
onPress: handleReport,
handlerName: null,
errorMessage: null,
customHandler: () => {
Alert.alert("Report Booking", "Report booking functionality is not yet available");
},
destructive: true,
gatingKey: null,
},
@@ -243,12 +196,14 @@ export default function BookingDetailIOS() {
id: "cancel",
label: "Cancel Event",
icon: "xmark.circle" as const,
onPress: handleCancel,
handlerName: "handleCancelBooking" as const,
errorMessage: "Unable to cancel. Please try again.",
destructive: true,
gatingKey: "cancel" as const,
},
];
// Filter actions based on gating logic
const filterAction = (action: { gatingKey: keyof typeof actions | null }) => {
if (action.gatingKey === null) return true;
const gating = actions[action.gatingKey];
@@ -260,17 +215,7 @@ export default function BookingDetailIOS() {
afterEvent: allAfterEventActions.filter(filterAction),
standalone: allStandaloneActions.filter(filterAction),
};
}, [
actions,
handleReschedule,
handleEditLocation,
handleAddGuests,
handleViewRecordings,
handleSessionDetails,
handleMarkNoShow,
handleReport,
handleCancel,
]);
}, [actions]);
return (
<>
@@ -316,7 +261,7 @@ export default function BookingDetailIOS() {
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
onPress={() => invokeHandler(action.handlerName, action.errorMessage)}
>
{action.label}
</Stack.Header.MenuAction>
@@ -329,7 +274,7 @@ export default function BookingDetailIOS() {
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
onPress={() => invokeHandler(action.handlerName, action.errorMessage)}
>
{action.label}
</Stack.Header.MenuAction>
@@ -342,7 +287,13 @@ export default function BookingDetailIOS() {
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
onPress={() => {
if (action.customHandler) {
action.customHandler();
} else if (action.handlerName && action.errorMessage) {
invokeHandler(action.handlerName, action.errorMessage);
}
}}
destructive
>
{action.label}
@@ -353,7 +304,14 @@ export default function BookingDetailIOS() {
</Stack.Header.Right>
</Stack.Header>
<BookingDetailScreen uid={uid} onActionsReady={handleActionsReady} />
<BookingDetailScreen
booking={booking}
isLoading={isLoading}
error={error ?? null}
refetch={refetch}
isRefetching={isRefetching}
onActionsReady={handleActionsReady}
/>
</>
);
}
@@ -1,9 +1,9 @@
import { Stack, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef } from "react";
import { Alert } from "react-native";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import { useAuth } from "@/contexts/AuthContext";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { useBookingByUid } from "@/hooks/useBookings";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
// Empty actions result for when no booking is loaded
@@ -33,7 +33,9 @@ export default function BookingDetail() {
"use no memo";
const { uid } = useLocalSearchParams<{ uid: string }>();
const { userInfo } = useAuth();
const [booking, setBooking] = useState<Booking | null>(null);
// Use React Query hook for booking data - single source of truth
const { data: booking, isLoading, error, refetch, isRefetching } = useBookingByUid(uid);
// Ref to store action handlers from BookingDetailScreen
const actionHandlersRef = useRef<ActionHandlers | null>(null);
@@ -43,17 +45,6 @@ export default function BookingDetail() {
actionHandlersRef.current = handlers;
}, []);
// Fetch booking data for the iOS header menu actions
useEffect(() => {
if (uid) {
CalComAPIService.getBookingByUid(uid)
.then(setBooking)
.catch(() => {
// Error handling is done in BookingDetailScreen
});
}
}, [uid]);
// Compute actions using centralized gating (same as BookingDetailScreen)
const actions = useMemo(() => {
if (!booking) return EMPTY_ACTIONS;
@@ -286,7 +277,14 @@ export default function BookingDetail() {
</Stack.Header.Right>
</Stack.Header>
<BookingDetailScreen uid={uid} onActionsReady={handleActionsReady} />
<BookingDetailScreen
booking={booking}
isLoading={isLoading}
error={error ?? null}
refetch={refetch}
isRefetching={isRefetching}
onActionsReady={handleActionsReady}
/>
{/* Action Modals for iOS header menu */}
</>
+10 -8
View File
@@ -38,17 +38,19 @@ export default function Bookings() {
type: "action",
label: option.label,
icon: {
name:
option.key === "upcoming"
? "calendar.badge.clock"
name: isSelected
? "checkmark.circle.fill"
: option.key === "upcoming"
? "calendar"
: option.key === "unconfirmed"
? "calendar.badge.exclamationmark"
: option.key === "past"
? "calendar.badge.checkmark"
: "calendar.badge.minus",
? "questionmark.circle"
: option.key === "recurring"
? "repeat.circle"
: option.key === "past"
? "checkmark.circle"
: "xmark.circle",
type: "sfSymbol",
},
state: isSelected ? "on" : "off",
onPress: () => {
handleFilterChange(option.key);
},
+96 -98
View File
@@ -1,56 +1,41 @@
import { Ionicons } from "@expo/vector-icons";
import SegmentedControl from "@react-native-segmented-control/segmented-control";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { useState } from "react";
import { Text, TextInput, TouchableOpacity, View } from "react-native";
import { Text, TextInput, View } from "react-native";
import { BookingListScreen } from "@/components/booking-list-screen/BookingListScreen";
import { Header } from "@/components/Header";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppPressable } from "@/components/AppPressable";
import { useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
import type { EventType } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
import { safeLogError } from "@/utils/safeLogger";
import { useEventTypes } from "@/hooks";
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 React Query hook for event types (same as iOS for unified caching)
const { data: eventTypes = [], isLoading: eventTypesLoading } = useEventTypes();
// Use the active booking filter hook
const { activeFilter, filterLabels, activeIndex, filterParams, handleSegmentChange } =
useActiveBookingFilter("upcoming", () => {
const { activeFilter, filterOptions, filterParams, handleFilterChange } = useActiveBookingFilter(
"upcoming",
() => {
// Clear dependent filters when status filter changes
setSearchQuery("");
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
});
}
);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const fetchEventTypes = async () => {
setEventTypesLoading(true);
try {
const types = await CalComAPIService.getEventTypes();
setEventTypes(types);
setEventTypesLoading(false);
} catch (err) {
safeLogError("Error fetching event types:", err);
// Error is logged but not displayed to user for event type filter
setEventTypesLoading(false);
}
};
const handleFilterButtonPress = () => {
setShowFilterModal(true);
if (eventTypes.length === 0) {
fetchEventTypes();
}
};
const clearEventTypeFilter = () => {
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
@@ -63,84 +48,97 @@ export default function Bookings() {
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 }}
/>
);
const renderFilterControls = () => {
const filterLabel =
selectedEventTypeId !== null ? selectedEventTypeLabel || "Event Type" : "Filter";
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}
<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">
{/* Dropdown menu for event type filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable
className="flex-row items-center rounded-lg border border-gray-200 bg-white"
style={{ paddingHorizontal: 8, paddingVertical: 6 }}
>
<Ionicons name="options-outline" size={14} color="#333" />
<Text
className={`text-sm ${selectedEventTypeId !== null ? "text-[#007AFF] font-semibold" : "text-[#333]"}`}
style={{ marginLeft: 4 }}
numberOfLines={1}
>
{filterLabel}
</Text>
<Ionicons name="chevron-down" size={12} color="#333" style={{ marginLeft: 2 }} />
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-52"
align="start"
>
<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>
{/* Clear filter option */}
<DropdownMenuCheckboxItem
checked={selectedEventTypeId === null}
onCheckedChange={() => handleEventTypeSelect(null)}
>
<Text className="text-base">All Event Types</Text>
</DropdownMenuCheckboxItem>
{/* Event type options */}
{eventTypes.map((eventType) => (
<DropdownMenuCheckboxItem
key={eventType.id}
checked={selectedEventTypeId === eventType.id}
onCheckedChange={() => handleEventTypeSelect(eventType.id, eventType.title)}
>
<Text className="text-base" numberOfLines={1}>
{eventType.title}
</Text>
</DropdownMenuCheckboxItem>
))}
{/* Loading state */}
{eventTypesLoading && eventTypes.length === 0 && (
<DropdownMenuCheckboxItem checked={false} onCheckedChange={() => {}}>
<Text className="text-base text-gray-500">Loading...</Text>
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<View style={{ flex: 1 }}>
<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>
{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>
</>
</View>
);
};
return (
<BookingListScreen
renderHeader={() => <Header />}
renderFilterControls={renderSegmentedControl}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
renderHeader={() => (
<Header
filterOptions={filterOptions}
activeFilter={activeFilter}
onFilterChange={handleFilterChange as (filterKey: string) => void}
/>
)}
renderFilterControls={renderFilterControls}
eventTypes={eventTypes}
eventTypesLoading={eventTypesLoading}
searchQuery={searchQuery}
@@ -14,11 +14,18 @@ import {
View,
} from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { AdvancedTab } from "@/components/event-type-detail/tabs/AdvancedTab";
import { AvailabilityTab } from "@/components/event-type-detail/tabs/AvailabilityTab";
import { BasicsTab } from "@/components/event-type-detail/tabs/BasicsTab";
import { LimitsTab } from "@/components/event-type-detail/tabs/LimitsTab";
import { RecurringTab } from "@/components/event-type-detail/tabs/RecurringTab";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { truncateTitle } from "@/components/event-type-detail/utils";
import { buildPartialUpdatePayload } from "@/components/event-type-detail/utils/buildPartialUpdatePayload";
import {
@@ -1113,19 +1120,72 @@ export default function EventTypeDetail() {
const saveButtonText = id === "new" ? "Create" : "Save";
const renderHeaderLeft = () => (
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="left">
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
);
const renderHeaderRight = () => (
<AppPressable
onPress={handleSave}
disabled={saving}
className={`px-2 py-2 ${saving ? "opacity-50" : ""}`}
>
<Text className="text-[16px] font-semibold text-[#007AFF]">{saveButtonText}</Text>
</AppPressable>
<HeaderButtonWrapper side="right">
<View className="flex-row items-center" style={{ gap: Platform.OS === "web" ? 24 : 8 }}>
{/* Tab Navigation Dropdown Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="flex-row items-center gap-1 px-2 py-2">
<Text className="text-[16px] font-semibold text-[#007AFF]" numberOfLines={1}>
{tabs.find((tab) => tab.id === activeTab)?.label ?? "Basics"}
</Text>
<Ionicons
name="chevron-down"
size={16}
color="#007AFF"
style={{ marginLeft: 2, flexShrink: 0 }}
/>
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-44"
align="end"
>
{tabs.map((tab) => {
const isSelected = activeTab === tab.id;
return (
<DropdownMenuItem key={tab.id} onPress={() => setActiveTab(tab.id)}>
<View className="flex-row items-center gap-2">
<Ionicons
name={isSelected ? "checkmark-circle" : tab.icon}
size={16}
color={isSelected ? "#007AFF" : "#666"}
/>
<Text
className={
isSelected ? "text-base font-semibold text-[#007AFF]" : "text-base"
}
>
{tab.label}
</Text>
</View>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Save Button */}
<AppPressable
onPress={handleSave}
disabled={saving}
className={`px-2 py-2 ${saving ? "opacity-50" : ""}`}
>
<Text className="text-[16px] font-semibold text-[#007AFF]">{saveButtonText}</Text>
</AppPressable>
</View>
</HeaderButtonWrapper>
);
return (
@@ -1155,10 +1215,9 @@ export default function EventTypeDetail() {
{tabs.map((tab) => (
<Stack.Header.MenuAction
key={tab.id}
isOn={activeTab === tab.id}
icon={
activeTab === tab.id
? "checkmark"
? "checkmark.circle.fill"
: tab.icon === "link"
? "link"
: tab.icon === "calendar"
@@ -1197,7 +1256,8 @@ export default function EventTypeDetail() {
contentContainerStyle={{ padding: 16, paddingBottom: 200 }}
contentInsetAdjustmentBehavior="automatic"
>
<Activity mode={Platform.OS !== "ios" ? "visible" : "hidden"}>
{/* Horizontal tabs only shown on web; Android uses header dropdown menu */}
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
<View
style={{
paddingBottom: 12,
@@ -1986,41 +2046,43 @@ export default function EventTypeDetail() {
</View>
) : null}
<View className="rounded-2xl bg-white p-5 mt-3 gap-3">
<View className="h-12 flex-row items-center justify-between">
<Text>Hidden</Text>
<Switch
value={isHidden}
onValueChange={setIsHidden}
trackColor={{ false: "#E5E5EA", true: "#000" }}
thumbColor="#FFFFFF"
/>
{activeTab === "basics" && (
<View className="rounded-2xl bg-white p-5 mt-3 gap-3">
<View className="h-12 flex-row items-center justify-between">
<Text>Hidden</Text>
<Switch
value={isHidden}
onValueChange={setIsHidden}
trackColor={{ false: "#E5E5EA", true: "#000" }}
thumbColor="#FFFFFF"
/>
</View>
<TouchableOpacity
className="h-12 flex-row items-center justify-between"
onPress={handlePreview}
>
<Text>Preview</Text>
<Ionicons name="open-outline" size={20} color="#000" />
</TouchableOpacity>
<TouchableOpacity
className="h-12 flex-row items-center justify-between"
onPress={handleCopyLink}
>
<Text>Copy Link</Text>
<Ionicons name="link-outline" size={20} color="#000" />
</TouchableOpacity>
<TouchableOpacity
className="h-12 flex-row items-center justify-between"
onPress={handleDelete}
>
<Text className="text-red-500">Delete</Text>
<Ionicons name="trash-outline" size={20} color="#ef4444" />
</TouchableOpacity>
</View>
<TouchableOpacity
className="h-12 flex-row items-center justify-between"
onPress={handlePreview}
>
<Text>Preview</Text>
<Ionicons name="open-outline" size={20} color="#000" />
</TouchableOpacity>
<TouchableOpacity
className="h-12 flex-row items-center justify-between"
onPress={handleCopyLink}
>
<Text>Copy Link</Text>
<Ionicons name="link-outline" size={20} color="#000" />
</TouchableOpacity>
<TouchableOpacity
className="h-12 flex-row items-center justify-between"
onPress={handleDelete}
>
<Text className="text-red-500">Delete</Text>
<Ionicons name="trash-outline" size={20} color="#ef4444" />
</TouchableOpacity>
</View>
)}
</ScrollView>
</View>
</>
@@ -7,7 +7,6 @@ import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import {
ActionSheetIOS,
Alert,
Pressable,
RefreshControl,
@@ -27,13 +26,13 @@ import {
useEventTypes,
useUserProfile,
} from "@/hooks";
import { useEventTypeFilter } from "@/hooks/useEventTypeFilter";
import { CalComAPIService, type EventType } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { getEventDuration } from "@/utils/getEventDuration";
import { offlineAwareRefresh } from "@/utils/network";
import { normalizeMarkdown } from "@/utils/normalizeMarkdown";
import { slugify } from "@/utils/slugify";
export default function EventTypesIOS() {
@@ -73,18 +72,34 @@ export default function EventTypesIOS() {
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
// Filter event types based on search query
// Event type filter and sort hook
const {
sortBy,
filters,
setSortBy,
toggleFilter,
resetFilters,
filteredAndSortedEventTypes,
activeFilterCount,
} = useEventTypeFilter();
// Filter event types based on search query and filter/sort options
const filteredEventTypes = useMemo(() => {
if (searchQuery.trim() === "") {
return eventTypes;
// First apply filter/sort from the hook
let filtered = filteredAndSortedEventTypes(eventTypes);
// Then apply search query filter
if (searchQuery.trim() !== "") {
const searchLower = searchQuery.toLowerCase();
filtered = filtered.filter(
(eventType) =>
eventType.title.toLowerCase().includes(searchLower) ||
eventType.description?.toLowerCase().includes(searchLower)
);
}
const searchLower = searchQuery.toLowerCase();
return eventTypes.filter(
(eventType) =>
eventType.title.toLowerCase().includes(searchLower) ||
eventType.description?.toLowerCase().includes(searchLower)
);
}, [eventTypes, searchQuery]);
return filtered;
}, [eventTypes, searchQuery, filteredAndSortedEventTypes]);
const handleSearch = (query: string) => {
setSearchQuery(query);
@@ -94,34 +109,6 @@ export default function EventTypesIOS() {
handleEdit(eventType);
};
const handleEventTypeLongPress = (eventType: EventType) => {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ["Cancel", "Edit", "Duplicate", "Delete"],
destructiveButtonIndex: 3, // Delete button
cancelButtonIndex: 0,
title: eventType.title,
message: eventType.description ? normalizeMarkdown(eventType.description) : undefined,
},
(buttonIndex) => {
switch (buttonIndex) {
case 1: // Edit
handleEdit(eventType);
break;
case 2: // Duplicate
handleDuplicate(eventType);
break;
case 3: // Delete
handleDelete(eventType);
break;
default:
// Cancel - do nothing
break;
}
}
);
};
const handleCopyLink = async (eventType: EventType) => {
try {
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
@@ -315,16 +302,21 @@ export default function EventTypesIOS() {
);
};
// Sort by menu handler (dummy for now)
const handleSortByOption = (option: string) => {
console.log("Sort by:", option);
// TODO: Implement actual sorting logic
// Sort by menu handler
const handleSortByOption = (option: "alphabetical" | "newest" | "duration") => {
setSortBy(option);
};
// Filter menu handler (dummy for now)
const handleFilterOption = (option: string) => {
console.log("Filter by:", option);
// TODO: Implement actual filtering logic
// Filter menu handler - toggle filters
const handleFilterToggle = (
filterKey:
| "hiddenOnly"
| "paidOnly"
| "seatedOnly"
| "requiresConfirmationOnly"
| "recurringOnly"
) => {
toggleFilter(filterKey);
};
if (loading) {
@@ -391,39 +383,66 @@ export default function EventTypesIOS() {
{/* Sort by Submenu - opens as separate submenu */}
<Stack.Header.Menu title="Sort by">
<Stack.Header.MenuAction
icon="textformat.abc"
icon={sortBy === "alphabetical" ? "checkmark.circle.fill" : "textformat.abc"}
onPress={() => handleSortByOption("alphabetical")}
>
Alphabetical
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon="calendar.badge.clock"
icon={sortBy === "newest" ? "checkmark.circle.fill" : "calendar.badge.clock"}
onPress={() => handleSortByOption("newest")}
>
Newest First
</Stack.Header.MenuAction>
<Stack.Header.MenuAction icon="clock" onPress={() => handleSortByOption("duration")}>
<Stack.Header.MenuAction
icon={sortBy === "duration" ? "checkmark.circle.fill" : "clock"}
onPress={() => handleSortByOption("duration")}
>
By Duration
</Stack.Header.MenuAction>
</Stack.Header.Menu>
{/* Filter Submenu - opens as separate submenu */}
<Stack.Header.Menu title="Filter">
{/* Filter Submenu - multi-select toggles */}
<Stack.Header.Menu
title={`Filter${activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}`}
>
<Stack.Header.MenuAction
icon="checkmark.circle"
onPress={() => handleFilterOption("all")}
icon={filters.hiddenOnly ? "checkmark.circle.fill" : "eye.slash"}
onPress={() => handleFilterToggle("hiddenOnly")}
>
All Event Types
</Stack.Header.MenuAction>
<Stack.Header.MenuAction icon="eye" onPress={() => handleFilterOption("active")}>
Active Only
Hidden Only
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon="dollarsign.circle"
onPress={() => handleFilterOption("paid")}
icon={filters.paidOnly ? "checkmark.circle.fill" : "dollarsign.circle"}
onPress={() => handleFilterToggle("paidOnly")}
>
Paid Events
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={filters.seatedOnly ? "checkmark.circle.fill" : "person.2"}
onPress={() => handleFilterToggle("seatedOnly")}
>
Seated Events
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={
filters.requiresConfirmationOnly ? "checkmark.circle.fill" : "checkmark.shield"
}
onPress={() => handleFilterToggle("requiresConfirmationOnly")}
>
Requires Confirmation
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={filters.recurringOnly ? "checkmark.circle.fill" : "repeat"}
onPress={() => handleFilterToggle("recurringOnly")}
>
Recurring
</Stack.Header.MenuAction>
{activeFilterCount > 0 && (
<Stack.Header.MenuAction icon="xmark.circle" onPress={resetFilters}>
Clear All Filters
</Stack.Header.MenuAction>
)}
</Stack.Header.Menu>
</Stack.Header.Menu>
@@ -469,6 +488,17 @@ export default function EventTypesIOS() {
description="Try searching with different keywords"
/>
</View>
) : filteredEventTypes.length === 0 && activeFilterCount > 0 ? (
<View className="flex-1 items-center justify-center bg-white p-5 pt-20">
<EmptyScreen
icon="filter-outline"
headline="No event types match your filters"
description="Try adjusting your filter criteria or clear all filters to see all event types"
buttonText="Clear Filters"
onButtonPress={resetFilters}
className="border-0"
/>
</View>
) : (
<View className="px-2 pt-4 md:px-4">
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
@@ -478,9 +508,7 @@ export default function EventTypesIOS() {
item={item}
index={index}
filteredEventTypes={filteredEventTypes}
copiedEventTypeId={null}
handleEventTypePress={handleEventTypePress}
handleEventTypeLongPress={handleEventTypeLongPress}
handleCopyLink={handleCopyLink}
handlePreview={handlePreview}
onEdit={handleEdit}
+70 -80
View File
@@ -4,7 +4,6 @@ import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import {
ActionSheetIOS,
Alert,
Platform,
RefreshControl,
@@ -37,6 +36,7 @@ import {
useDuplicateEventType,
useEventTypes,
} from "@/hooks";
import { useEventTypeFilter } from "@/hooks/useEventTypeFilter";
import { CalComAPIService, type EventType } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
@@ -94,18 +94,12 @@ export default function EventTypes() {
// Toast state for web platform
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState("");
const [copiedEventTypeId, setCopiedEventTypeId] = useState<number | null>(null);
// Function to show toast
const showToastMessage = (message: string, eventTypeId?: number) => {
const showToastMessage = (message: string) => {
setToastMessage(message);
setShowToast(true);
if (eventTypeId) {
setCopiedEventTypeId(eventTypeId);
}
setTimeout(() => {
setShowToast(false);
setCopiedEventTypeId(null);
}, 2000);
};
@@ -113,18 +107,34 @@ export default function EventTypes() {
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
// Filter event types based on search query
// Event type filter and sort hook
const {
sortBy,
filters,
setSortBy,
toggleFilter,
resetFilters,
filteredAndSortedEventTypes,
activeFilterCount,
} = useEventTypeFilter();
// Filter event types based on search query and filter/sort options
const filteredEventTypes = useMemo(() => {
if (searchQuery.trim() === "") {
return eventTypes;
// First apply filter/sort from the hook
let filtered = filteredAndSortedEventTypes(eventTypes);
// Then apply search query filter
if (searchQuery.trim() !== "") {
const searchLower = searchQuery.toLowerCase();
filtered = filtered.filter(
(eventType) =>
eventType.title.toLowerCase().includes(searchLower) ||
eventType.description?.toLowerCase().includes(searchLower)
);
}
const searchLower = searchQuery.toLowerCase();
return eventTypes.filter(
(eventType) =>
eventType.title.toLowerCase().includes(searchLower) ||
eventType.description?.toLowerCase().includes(searchLower)
);
}, [eventTypes, searchQuery]);
return filtered;
}, [eventTypes, searchQuery, filteredAndSortedEventTypes]);
const handleSearch = (query: string) => {
setSearchQuery(query);
@@ -134,53 +144,13 @@ export default function EventTypes() {
handleEdit(eventType);
};
const handleEventTypeLongPress = (eventType: EventType) => {
if (Platform.OS === "web") {
// Show custom modal for web platform
setSelectedEventType(eventType);
setShowActionModal(true);
return;
}
// Android handles long-press via DropdownMenu in EventTypeListItem.android.tsx
if (Platform.OS === "android") {
return;
}
ActionSheetIOS.showActionSheetWithOptions(
{
options: ["Cancel", "Edit", "Duplicate", "Delete"],
destructiveButtonIndex: 3, // Delete button
cancelButtonIndex: 0,
title: eventType.title,
message: eventType.description ? normalizeMarkdown(eventType.description) : undefined,
},
(buttonIndex) => {
switch (buttonIndex) {
case 1: // Edit
handleEdit(eventType);
break;
case 2: // Duplicate
handleDuplicate(eventType);
break;
case 3: // Delete
handleDelete(eventType);
break;
default:
// Cancel - do nothing
break;
}
}
);
};
const handleCopyLink = async (eventType: EventType) => {
try {
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
await Clipboard.setStringAsync(link);
if (Platform.OS === "web") {
showToastMessage("Link copied!", eventType.id);
showToastMessage("Link copied!");
} else {
Alert.alert("Link Copied", "Event type link copied!");
}
@@ -532,7 +502,7 @@ export default function EventTypes() {
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : "light"} // Only looks cool on iOS 18 and below
hidden={Platform.OS === "android"}
hidden={Platform.OS === "android" || Platform.OS === "web"}
>
<Stack.Header.Title large>Event Types</Stack.Header.Title>
<Stack.Header.Right>
@@ -549,7 +519,16 @@ export default function EventTypes() {
</Stack.Header>
{(Platform.OS === "web" || Platform.OS === "android") && (
<>
<Header />
<Header
eventTypeFilterConfig={{
sortBy,
filters,
onSortChange: setSortBy,
onToggleFilter: toggleFilter,
onResetFilters: resetFilters,
activeFilterCount,
}}
/>
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
@@ -579,26 +558,37 @@ export default function EventTypes() {
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
<View className="px-2 pt-4 md:px-4">
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
{filteredEventTypes.map((item, index) => (
<EventTypeListItem
key={item.id.toString()}
item={item}
index={index}
filteredEventTypes={filteredEventTypes}
copiedEventTypeId={copiedEventTypeId}
handleEventTypePress={handleEventTypePress}
handleEventTypeLongPress={handleEventTypeLongPress}
handleCopyLink={handleCopyLink}
handlePreview={handlePreview}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
))}
{filteredEventTypes.length === 0 && activeFilterCount > 0 ? (
<View className="flex-1 items-center justify-center bg-white p-5 pt-20">
<EmptyScreen
icon="filter-outline"
headline="No event types match your filters"
description="Try adjusting your filter criteria or clear all filters to see all event types"
buttonText="Clear Filters"
onButtonPress={resetFilters}
className="border-0"
/>
</View>
</View>
) : (
<View className="px-2 pt-4 md:px-4">
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
{filteredEventTypes.map((item, index) => (
<EventTypeListItem
key={item.id.toString()}
item={item}
index={index}
filteredEventTypes={filteredEventTypes}
handleEventTypePress={handleEventTypePress}
handleCopyLink={handleCopyLink}
handlePreview={handlePreview}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
))}
</View>
</View>
)}
</ScrollView>
{/* Create Event Type Modal - Android uses AlertDialog */}
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from "expo-router";
export default function TabsIndex() {
return <Redirect href="/(tabs)/(event-types)" />;
}
+15 -10
View File
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen";
import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
@@ -43,22 +44,26 @@ export default function AddGuests() {
const renderHeaderLeft = useCallback(
() => (
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="left">
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[router]
);
const renderHeaderRight = useCallback(
() => (
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
>
<Ionicons name="checkmark" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
>
<Ionicons name="checkmark" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[handleSave, isSaving]
);
+11 -8
View File
@@ -2,6 +2,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen";
import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen";
import { CalComAPIService, type Schedule } from "@/services/calcom";
@@ -65,14 +66,16 @@ export default function EditAvailabilityDay() {
options={{
title: dayName,
headerRight: () => (
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
<HeaderButtonWrapper side="right">
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
</HeaderButtonWrapper>
),
}}
/>
+11 -8
View File
@@ -2,6 +2,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen";
import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen";
import { CalComAPIService, type Schedule } from "@/services/calcom";
@@ -60,14 +61,16 @@ export default function EditAvailabilityName() {
options={{
title: "Edit Name & Timezone",
headerRight: () => (
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
<HeaderButtonWrapper side="right">
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
</HeaderButtonWrapper>
),
}}
/>
+11 -8
View File
@@ -2,6 +2,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen";
import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen";
import { CalComAPIService, type Schedule } from "@/services/calcom";
@@ -77,14 +78,16 @@ export default function EditAvailabilityOverride() {
options={{
title,
headerRight: () => (
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
<HeaderButtonWrapper side="right">
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
</HeaderButtonWrapper>
),
}}
/>
+15 -10
View File
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen";
import EditLocationScreenComponent from "@/components/screens/EditLocationScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
@@ -43,22 +44,26 @@ export default function EditLocation() {
const renderHeaderLeft = useCallback(
() => (
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="left">
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[router]
);
const renderHeaderRight = useCallback(
() => (
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
>
<Ionicons name="checkmark" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
>
<Ionicons name="checkmark" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[handleSave, isSaving]
);
+6 -3
View File
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
@@ -62,9 +63,11 @@ export default function MarkNoShow() {
const renderHeaderLeft = useCallback(
() => (
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="left">
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[router]
);
+6 -3
View File
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { ConferencingSession } from "@/services/types/bookings.types";
@@ -34,9 +35,11 @@ export default function MeetingSessionDetails() {
const renderHeaderLeft = useCallback(
() => (
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="left">
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[router]
);
+6 -3
View File
@@ -12,6 +12,7 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { useUserProfile } from "@/hooks";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
@@ -116,9 +117,11 @@ export default function ProfileSheet() {
},
headerLeft: () => null,
headerRight: () => (
<TouchableOpacity onPress={handleClose} style={{ padding: 8 }}>
<Ionicons name="close" size={24} color="#000" />
</TouchableOpacity>
<HeaderButtonWrapper side="right">
<TouchableOpacity onPress={handleClose} style={{ padding: 8 }}>
<Ionicons name="close" size={24} color="#000" />
</TouchableOpacity>
</HeaderButtonWrapper>
),
}}
/>
+15 -10
View File
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen";
import RescheduleScreenComponent from "@/components/screens/RescheduleScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
@@ -43,22 +44,26 @@ export default function Reschedule() {
const renderHeaderLeft = useCallback(
() => (
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="left">
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[router]
);
const renderHeaderRight = useCallback(
() => (
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
>
<Ionicons name="checkmark" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`px-2 py-2 ${isSaving ? "opacity-50" : ""}`}
>
<Ionicons name="checkmark" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[handleSave, isSaving]
);
+6 -3
View File
@@ -3,6 +3,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { BookingRecording } from "@/services/types/bookings.types";
@@ -34,9 +35,11 @@ export default function ViewRecordings() {
const renderHeaderLeft = useCallback(
() => (
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
<HeaderButtonWrapper side="left">
<AppPressable onPress={() => router.back()} className="px-2 py-2">
<Ionicons name="close" size={24} color="#007AFF" />
</AppPressable>
</HeaderButtonWrapper>
),
[router]
);
+2 -2
View File
@@ -66,8 +66,8 @@ function ActionButton({
}: ActionButtonProps) {
if (!visible) return null;
const iconColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#6B7280";
const textColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#111827";
const iconColor = !enabled ? "#D1D5DB" : isDanger ? "#800020" : "#6B7280";
const textColor = !enabled ? "#D1D5DB" : isDanger ? "#800020" : "#111827";
return (
<TouchableOpacity
-1
View File
@@ -40,7 +40,6 @@ export function EmptyScreen({
className="flex-row items-center justify-center gap-1 rounded-lg bg-gray-900 px-4 py-2.5"
onPress={onButtonPress}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-medium text-white">{buttonText}</Text>
</TouchableOpacity>
) : null}
+363 -4
View File
@@ -1,13 +1,56 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Image, Platform, TouchableOpacity, View } from "react-native";
import { ActivityIndicator, Image, Platform, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, type UserProfile } from "@/services/calcom";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { CalComLogo } from "./CalComLogo";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppPressable } from "@/components/AppPressable";
import type { EventTypeFilters, EventTypeSortOption } from "@/hooks/useEventTypeFilter";
export function Header() {
interface FilterOption {
key: string;
label: string;
}
interface EventTypeFilterConfig {
sortBy: EventTypeSortOption;
filters: EventTypeFilters;
onSortChange: (sort: EventTypeSortOption) => void;
onToggleFilter: (filterKey: keyof EventTypeFilters) => void;
onResetFilters: () => void;
activeFilterCount: number;
}
interface HeaderProps {
/** Optional: Filter options for dropdown menu (e.g., booking status filter) */
filterOptions?: FilterOption[];
/** Optional: Currently active filter key */
activeFilter?: string;
/** Optional: Callback when filter changes */
onFilterChange?: (filterKey: string) => void;
/** Optional: Event type filter/sort config for Android */
eventTypeFilterConfig?: EventTypeFilterConfig;
}
export function Header({
filterOptions,
activeFilter,
onFilterChange,
eventTypeFilterConfig,
}: HeaderProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
@@ -38,6 +81,9 @@ export function Header() {
router.push("/profile-sheet");
};
const activeFilterLabel =
filterOptions?.find((opt) => opt.key === activeFilter)?.label ?? "Filter";
return (
<View
className="flex-row items-center justify-between border-b border-[#E5E5EA] bg-white px-2 md:px-4"
@@ -48,11 +94,324 @@ export function Header() {
<CalComLogo width={101} height={22} color="#333" />
</View>
{/* Right: Icons */}
{/* Right: Filter Dropdown + Profile */}
<View
className="flex-row items-center gap-4"
className="flex-row items-center gap-2"
style={Platform.OS === "web" ? { marginRight: 8 } : {}}
>
{/* Booking status filter dropdown (for bookings page) */}
{filterOptions && filterOptions.length > 0 && onFilterChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="flex-row items-center gap-1 px-2 py-2">
<Text className="text-[16px] font-semibold text-[#007AFF]">
{activeFilterLabel}
</Text>
<Ionicons name="chevron-down" size={16} color="#007AFF" />
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-44"
align="end"
>
{filterOptions.map((option) => {
const isSelected = activeFilter === option.key;
// Map filter keys to appropriate icons
const getFilterIcon = (key: string) => {
switch (key) {
case "upcoming":
return "calendar-outline";
case "unconfirmed":
return "help-circle-outline";
case "recurring":
return "repeat-outline";
case "past":
return "checkmark-circle-outline";
case "cancelled":
return "close-circle-outline";
default:
return "calendar-outline";
}
};
return (
<DropdownMenuItem key={option.key} onPress={() => onFilterChange(option.key)}>
<View className="flex-row items-center gap-2">
<Ionicons
name={isSelected ? "checkmark-circle" : getFilterIcon(option.key)}
size={16}
color={isSelected ? "#007AFF" : "#666"}
/>
<Text
className={
isSelected ? "text-base font-semibold text-[#007AFF]" : "text-base"
}
>
{option.label}
</Text>
</View>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Event type filter/sort menu (for event types page - Android and Web/Extension) */}
{eventTypeFilterConfig && (Platform.OS === "android" || Platform.OS === "web") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="relative p-2">
<Ionicons name="options-outline" size={22} color="#007AFF" />
{/* Badge for active filters */}
{eventTypeFilterConfig.activeFilterCount > 0 && (
<View
className="absolute -top-0.5 -right-0.5 items-center justify-center rounded-full bg-[#007AFF]"
style={{ width: 16, height: 16 }}
>
<Text className="text-[10px] font-bold text-white">
{eventTypeFilterConfig.activeFilterCount}
</Text>
</View>
)}
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-56"
align="end"
>
{/* Sort by Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<View className="flex-row items-center gap-2">
<Ionicons name="swap-vertical-outline" size={16} color="#666" />
<Text className="text-base">Sort by</Text>
</View>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onSortChange("alphabetical")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "alphabetical"
? "checkmark-circle"
: "text-outline"
}
size={16}
color={eventTypeFilterConfig.sortBy === "alphabetical" ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "alphabetical"
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Alphabetical
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onSortChange("newest")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "newest"
? "checkmark-circle"
: "calendar-outline"
}
size={16}
color={eventTypeFilterConfig.sortBy === "newest" ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "newest"
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Newest First
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onSortChange("duration")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "duration"
? "checkmark-circle"
: "time-outline"
}
size={16}
color={eventTypeFilterConfig.sortBy === "duration" ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "duration"
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
By Duration
</Text>
</View>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{/* Filter Label */}
<DropdownMenuLabel>
<View className="flex-row items-center gap-2">
<Ionicons name="filter-outline" size={16} color="#666" />
<Text className="text-sm font-medium text-gray-500">Filters</Text>
</View>
</DropdownMenuLabel>
{/* Filter Toggles (multi-select) */}
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("hiddenOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.hiddenOnly
? "checkmark-circle"
: "eye-off-outline"
}
size={16}
color={eventTypeFilterConfig.filters.hiddenOnly ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.filters.hiddenOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Hidden Only
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("paidOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.paidOnly ? "checkmark-circle" : "cash-outline"
}
size={16}
color={eventTypeFilterConfig.filters.paidOnly ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.filters.paidOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Paid Events
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("seatedOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.seatedOnly
? "checkmark-circle"
: "people-outline"
}
size={16}
color={eventTypeFilterConfig.filters.seatedOnly ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.filters.seatedOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Seated Events
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onToggleFilter("requiresConfirmationOnly")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? "checkmark-circle"
: "checkmark-circle-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.requiresConfirmationOnly ? "#007AFF" : "#666"
}
/>
<Text
className={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Requires Confirmation
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onToggleFilter("recurringOnly")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.recurringOnly
? "checkmark-circle"
: "repeat-outline"
}
size={16}
color={eventTypeFilterConfig.filters.recurringOnly ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.filters.recurringOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Recurring
</Text>
</View>
</DropdownMenuItem>
{/* Clear All - only show when filters are active */}
{eventTypeFilterConfig.activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onPress={eventTypeFilterConfig.onResetFilters}>
<View className="flex-row items-center gap-2">
<Ionicons name="close-circle-outline" size={16} color="#FF3B30" />
<Text className="text-base text-[#FF3B30]">Clear All Filters</Text>
</View>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Profile Picture */}
<TouchableOpacity onPress={handleProfile} className="p-1">
{loading ? (
@@ -0,0 +1,19 @@
import { Platform, View } from "react-native";
interface HeaderButtonWrapperProps {
children: React.ReactNode;
side: "left" | "right";
}
const WEB_HEADER_INSET = 12;
export function HeaderButtonWrapper({ children, side }: HeaderButtonWrapperProps) {
if (Platform.OS !== "web") {
return <>{children}</>;
}
const style =
side === "left" ? { marginLeft: WEB_HEADER_INSET } : { marginRight: WEB_HEADER_INSET };
return <View style={style}>{children}</View>;
}
@@ -1,137 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text } from "@/components/ui/text";
import type { AvailabilityListItemProps } from "./AvailabilityListItem";
import { AvailabilitySlots, ScheduleName, TimeZoneRow } from "./AvailabilityListItemParts";
export const AvailabilityListItem = ({
item: schedule,
index: _index,
handleSchedulePress,
handleScheduleLongPress: _handleScheduleLongPress,
setSelectedSchedule: _setSelectedSchedule,
setShowActionsModal: _setShowActionsModal,
onDuplicate,
onDelete,
onSetAsDefault,
}: AvailabilityListItemProps) => {
const insets = useSafeAreaInsets();
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
// Define dropdown menu actions based on schedule state
type DropdownAction = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
variant?: "default" | "destructive";
};
const scheduleActions: DropdownAction[] = [
...(!schedule.isDefault && onSetAsDefault
? [
{
label: "Set as Default",
icon: "star-outline" as const,
onPress: () => onSetAsDefault(schedule),
variant: "default" as const,
},
]
: []),
...(onDuplicate
? [
{
label: "Duplicate",
icon: "copy-outline" as const,
onPress: () => onDuplicate(schedule),
variant: "default" as const,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
icon: "trash-outline" as const,
onPress: () => onDelete(schedule),
variant: "destructive" as const,
},
]
: []),
];
// Find the index where destructive actions start
const destructiveStartIndex = scheduleActions.findIndex(
(action) => action.variant === "destructive"
);
return (
<View className="border-b border-cal-border bg-cal-bg">
<View
className="flex-row items-center"
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<Pressable
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
style={{ minWidth: 0 }}
>
<View style={{ flex: 1 }}>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
</Pressable>
{/* Dropdown Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32, flexShrink: 0 }}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent insets={contentInsets} sideOffset={8} className="w-44" align="end">
{scheduleActions.map((action, index) => (
<React.Fragment key={action.label}>
{/* Add separator before destructive actions */}
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#DC2626" : "#374151"}
style={{ marginRight: 8 }}
/>
<Text className={action.variant === "destructive" ? "text-destructive" : ""}>
{action.label}
</Text>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
</View>
);
};
@@ -1,19 +1,22 @@
import { TouchableOpacity, View } from "react-native";
import type { Schedule } from "@/hooks";
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
AvailabilitySlots,
ScheduleActionsButton,
ScheduleName,
TimeZoneRow,
} from "./AvailabilityListItemParts";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text } from "@/components/ui/text";
import type { Schedule } from "@/hooks";
import { AvailabilitySlots, ScheduleName, TimeZoneRow } from "./AvailabilityListItemParts";
export interface AvailabilityListItemProps {
item: Schedule;
index: number;
handleSchedulePress: (schedule: Schedule) => void;
handleScheduleLongPress: (schedule: Schedule) => void;
setSelectedSchedule: (schedule: Schedule) => void;
setShowActionsModal: (show: boolean) => void;
onDuplicate?: (schedule: Schedule) => void;
onDelete?: (schedule: Schedule) => void;
onSetAsDefault?: (schedule: Schedule) => void;
@@ -22,31 +25,116 @@ export interface AvailabilityListItemProps {
export const AvailabilityListItem = ({
item: schedule,
handleSchedulePress,
handleScheduleLongPress,
setSelectedSchedule,
setShowActionsModal,
onDuplicate,
onDelete,
onSetAsDefault,
}: AvailabilityListItemProps) => {
const insets = useSafeAreaInsets();
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
type DropdownAction = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
variant?: "default" | "destructive";
};
const scheduleActions: DropdownAction[] = [
...(!schedule.isDefault && onSetAsDefault
? [
{
label: "Set as Default",
icon: "star-outline" as const,
onPress: () => onSetAsDefault(schedule),
variant: "default" as const,
},
]
: []),
...(onDuplicate
? [
{
label: "Duplicate",
icon: "copy-outline" as const,
onPress: () => onDuplicate(schedule),
variant: "default" as const,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
icon: "trash-outline" as const,
onPress: () => onDelete(schedule),
variant: "destructive" as const,
},
]
: []),
];
const destructiveStartIndex = scheduleActions.findIndex(
(action) => action.variant === "destructive"
);
return (
<TouchableOpacity
className="border-b border-cal-border bg-cal-bg active:bg-cal-bg-secondary"
onPress={() => handleSchedulePress(schedule)}
onLongPress={() => handleScheduleLongPress(schedule)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<View className="mr-4 flex-1" style={{ minWidth: 0 }}>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
<View style={{ flexShrink: 0 }}>
<ScheduleActionsButton
schedule={schedule}
setSelectedSchedule={setSelectedSchedule}
setShowActionsModal={setShowActionsModal}
/>
</View>
<View className="border-b border-cal-border bg-cal-bg">
<View
className="flex-row items-center"
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<Pressable
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
style={{ minWidth: 0 }}
>
<View style={{ flex: 1 }}>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
</Pressable>
{scheduleActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32, flexShrink: 0 }}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent insets={contentInsets} sideOffset={8} className="w-44" align="end">
{scheduleActions.map((action, index) => (
<React.Fragment key={action.label}>
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#800020" : "#374151"}
style={{ marginRight: 8 }}
/>
<Text className={action.variant === "destructive" ? "text-destructive" : ""}>
{action.label}
</Text>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</View>
</TouchableOpacity>
</View>
);
};
@@ -1,227 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text } from "@/components/ui/text";
import { getBookingActions } from "@/utils/booking-actions";
import {
BadgesRow,
BookingDescription,
BookingTitle,
ConfirmRejectButtons,
HostAndAttendees,
MeetingLink,
TimeAndDateRow,
} from "./BookingListItemParts";
import type { BookingListItemProps } from "./types";
import { useBookingListItemData } from "./useBookingListItemData";
export const BookingListItem: React.FC<BookingListItemProps> = ({
booking,
userEmail,
isConfirming,
isDeclining,
onPress,
onLongPress: _onLongPress,
onConfirm,
onReject,
onActionsPress: _onActionsPress,
onReschedule,
onEditLocation,
onAddGuests,
onViewRecordings,
onMeetingSessionDetails,
onMarkNoShow,
onReportBooking,
onCancelBooking,
}) => {
const {
isUpcoming,
isPending,
isCancelled,
isRejected,
hostAndAttendeesDisplay,
meetingInfo,
hasNoShowAttendee,
formattedDate,
formattedTimeRange,
} = useBookingListItemData(booking, userEmail);
const insets = useSafeAreaInsets();
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
// Use centralized action gating for consistency
const actions = React.useMemo(() => {
return getBookingActions({
booking,
eventType: undefined,
currentUserId: undefined,
currentUserEmail: userEmail,
isOnline: true,
});
}, [booking, userEmail]);
// Define dropdown menu actions based on booking state
type DropdownAction = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
variant?: "default" | "destructive";
};
const allActions: (DropdownAction & { visible: boolean })[] = [
// Edit Event Section
{
label: "Reschedule Booking",
icon: "calendar-outline",
onPress: () => onReschedule?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
},
{
label: "Edit Location",
icon: "location-outline",
onPress: () => onEditLocation?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
},
{
label: "Add Guests",
icon: "person-add-outline",
onPress: () => onAddGuests?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
},
// After Event Section
{
label: "View Recordings",
icon: "videocam-outline",
onPress: () => onViewRecordings?.(booking),
variant: "default" as const,
visible:
actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
},
{
label: "Meeting Session Details",
icon: "information-circle-outline",
onPress: () => onMeetingSessionDetails?.(booking),
variant: "default" as const,
visible:
actions.meetingSessionDetails.visible &&
actions.meetingSessionDetails.enabled &&
!!onMeetingSessionDetails,
},
{
label: "Mark as No-Show",
icon: "eye-off-outline",
onPress: () => onMarkNoShow?.(booking),
variant: "default" as const,
visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
},
// Other Actions
{
label: "Report Booking",
icon: "flag-outline",
onPress: () => onReportBooking?.(booking),
variant: "destructive" as const,
visible: !!onReportBooking,
},
{
label: "Cancel Event",
icon: "close-circle-outline",
onPress: () => onCancelBooking?.(booking),
variant: "destructive" as const,
visible: isUpcoming && !isCancelled && !!onCancelBooking,
},
];
const visibleActions = allActions.filter((action) => action.visible);
// Find the index where destructive actions start
const destructiveStartIndex = visibleActions.findIndex(
(action) => action.variant === "destructive"
);
return (
<View className="border-b border-cal-border bg-cal-bg">
<Pressable
onPress={() => onPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
className="active:bg-cal-bg-secondary"
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
>
<TimeAndDateRow formattedDate={formattedDate} formattedTimeRange={formattedTimeRange} />
<BadgesRow isPending={isPending} />
<BookingTitle title={booking.title} isCancelled={isCancelled} isRejected={isRejected} />
<BookingDescription description={booking.description} />
<HostAndAttendees
hostAndAttendeesDisplay={hostAndAttendeesDisplay}
hasNoShowAttendee={hasNoShowAttendee}
/>
<MeetingLink meetingInfo={meetingInfo} />
</Pressable>
<View
className="flex-row items-center justify-end"
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
>
<ConfirmRejectButtons
booking={booking}
isPending={isPending}
isConfirming={isConfirming}
isDeclining={isDeclining}
onConfirm={onConfirm}
onReject={onReject}
/>
{/* Dropdown Menu - only show when there are visible actions */}
{visibleActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent insets={contentInsets} sideOffset={8} className="w-52" align="end">
{visibleActions.map((action, index) => (
<React.Fragment key={action.label}>
{/* Add separator before destructive actions */}
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#DC2626" : "#374151"}
style={{ marginRight: 8 }}
/>
<Text className={action.variant === "destructive" ? "text-destructive" : ""}>
{action.label}
</Text>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</View>
</View>
);
};
@@ -25,7 +25,6 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
onPress,
onConfirm,
onReject,
onActionsPress: _onActionsPress,
onReschedule,
onEditLocation,
onAddGuests,
@@ -1,6 +1,16 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { TouchableOpacity, View } from "react-native";
import React from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text } from "@/components/ui/text";
import { getBookingActions } from "@/utils/booking-actions";
import {
BadgesRow,
BookingDescription,
@@ -19,12 +29,19 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
isConfirming,
isDeclining,
onPress,
onLongPress,
onConfirm,
onReject,
onActionsPress,
onReschedule,
onEditLocation,
onAddGuests,
onViewRecordings,
onMeetingSessionDetails,
onMarkNoShow,
onReportBooking,
onCancelBooking,
}) => {
const {
isUpcoming,
isPending,
isCancelled,
isRejected,
@@ -35,13 +52,108 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
formattedTimeRange,
} = useBookingListItemData(booking, userEmail);
const insets = useSafeAreaInsets();
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
const actions = React.useMemo(() => {
return getBookingActions({
booking,
eventType: undefined,
currentUserId: undefined,
currentUserEmail: userEmail,
isOnline: true,
});
}, [booking, userEmail]);
type DropdownAction = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
variant?: "default" | "destructive";
};
const allActions: (DropdownAction & { visible: boolean })[] = [
{
label: "Reschedule Booking",
icon: "calendar-outline",
onPress: () => onReschedule?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isRejected && !isPending && !!onReschedule,
},
{
label: "Edit Location",
icon: "location-outline",
onPress: () => onEditLocation?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isRejected && !isPending && !!onEditLocation,
},
{
label: "Add Guests",
icon: "person-add-outline",
onPress: () => onAddGuests?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isRejected && !isPending && !!onAddGuests,
},
{
label: "View Recordings",
icon: "videocam-outline",
onPress: () => onViewRecordings?.(booking),
variant: "default" as const,
visible:
actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
},
{
label: "Meeting Session Details",
icon: "information-circle-outline",
onPress: () => onMeetingSessionDetails?.(booking),
variant: "default" as const,
visible:
actions.meetingSessionDetails.visible &&
actions.meetingSessionDetails.enabled &&
!!onMeetingSessionDetails,
},
{
label: "Mark as No-Show",
icon: "eye-off-outline",
onPress: () => onMarkNoShow?.(booking),
variant: "default" as const,
visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
},
{
label: "Report Booking",
icon: "flag-outline",
onPress: () => onReportBooking?.(booking),
variant: "destructive" as const,
visible: !!onReportBooking,
},
{
label: "Cancel Event",
icon: "close-circle-outline",
onPress: () => onCancelBooking?.(booking),
variant: "destructive" as const,
visible: isUpcoming && !isCancelled && !isRejected && !!onCancelBooking,
},
];
const visibleActions = allActions.filter((action) => action.visible);
const destructiveStartIndex = visibleActions.findIndex(
(action) => action.variant === "destructive"
);
return (
<View className="border-b border-cal-border bg-cal-bg">
<TouchableOpacity
className="active:bg-cal-bg-secondary"
<Pressable
onPress={() => onPress(booking)}
onLongPress={() => onLongPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
className="active:bg-cal-bg-secondary"
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
>
<TimeAndDateRow formattedDate={formattedDate} formattedTimeRange={formattedTimeRange} />
<BadgesRow isPending={isPending} />
@@ -52,7 +164,7 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
hasNoShowAttendee={hasNoShowAttendee}
/>
<MeetingLink meetingInfo={meetingInfo} />
</TouchableOpacity>
</Pressable>
<View
className="flex-row items-center justify-end"
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
@@ -65,16 +177,40 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
onConfirm={onConfirm}
onReject={onReject}
/>
<TouchableOpacity
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
onPress={(e) => {
e.stopPropagation();
onActionsPress(booking);
}}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
{visibleActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent insets={contentInsets} sideOffset={8} className="w-52" align="end">
{visibleActions.map((action, index) => (
<React.Fragment key={action.label}>
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#800020" : "#374151"}
style={{ marginRight: 8 }}
/>
<Text className={action.variant === "destructive" ? "text-destructive" : ""}>
{action.label}
</Text>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</View>
</View>
);
@@ -80,7 +80,7 @@ export function HostAndAttendees({
<Text className="text-sm text-cal-text">{hostAndAttendeesDisplay}</Text>
{hasNoShowAttendee && (
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-1.5 py-0.5">
<Ionicons name="eye-off" size={10} color="#DC2626" />
<Ionicons name="eye-off" size={10} color="#800020" />
<Text className="ml-0.5 text-[10px] font-medium text-cal-accent-destructive">
No-show
</Text>
@@ -0,0 +1,350 @@
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Pressable, Text, TouchableOpacity, View, Linking } from "react-native";
import type { SFSymbols7_0 } from "sf-symbols-typescript";
import type { Booking } from "@/services/calcom";
import type { RecurringBookingGroup } from "@/utils/bookings-utils";
import { formatDate, formatTime, getHostAndAttendeesDisplay } from "@/utils/bookings-utils";
import { getMeetingInfo } from "@/utils/meetings-utils";
import { SvgImage } from "@/components/SvgImage";
import { showErrorAlert } from "@/utils/alerts";
import { getBookingActions } from "@/utils/booking-actions";
export interface RecurringBookingListItemProps {
group: RecurringBookingGroup;
userEmail?: string;
isConfirmingAll?: boolean;
isDecliningAll?: boolean;
isCancellingAll?: boolean;
onPress: (group: RecurringBookingGroup) => void;
onLongPress?: (group: RecurringBookingGroup) => void;
onConfirmAll?: (group: RecurringBookingGroup) => void;
onRejectAll?: (group: RecurringBookingGroup) => void;
onCancelAllRemaining?: (group: RecurringBookingGroup) => void;
// Individual booking actions
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;
}
export const RecurringBookingListItem: React.FC<RecurringBookingListItemProps> = ({
group,
userEmail,
isConfirmingAll = false,
isDecliningAll = false,
isCancellingAll = false,
onPress,
onLongPress: _onLongPress, // Not used on iOS - native ContextMenu handles long-press
onConfirmAll,
onRejectAll,
onCancelAllRemaining,
onReschedule,
onEditLocation,
onAddGuests,
onViewRecordings,
onMeetingSessionDetails,
onMarkNoShow,
onReportBooking,
onCancelBooking,
}) => {
const booking = group.firstUpcoming;
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isUpcoming = new Date(endTime) >= new Date();
const isCancelled = booking.status?.toLowerCase() === "cancelled";
const isRejected = booking.status?.toLowerCase() === "rejected";
const isPending = booking.status?.toLowerCase() === "pending" || booking.requiresConfirmation;
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
const meetingInfo = getMeetingInfo(booking.location);
const formattedDate = formatDate(startTime, isUpcoming);
const formattedTimeRange = `${formatTime(startTime)} - ${formatTime(endTime)}`;
const isProcessing = isConfirmingAll || isDecliningAll || isCancellingAll;
// Use centralized action gating for consistency
const actions = React.useMemo(() => {
return getBookingActions({
booking,
eventType: undefined,
currentUserId: undefined,
currentUserEmail: userEmail,
isOnline: true,
});
}, [booking, userEmail]);
// Define context menu actions based on booking state
type ContextMenuAction = {
label: string;
icon: SFSymbols7_0;
onPress: () => void;
role: "default" | "destructive";
};
const allActions: (ContextMenuAction & { visible: boolean })[] = [
// Edit Event Section
{
label: "Reschedule Booking",
icon: "calendar" as const,
onPress: () => onReschedule?.(booking),
role: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
},
{
label: "Edit Location",
icon: "location" as const,
onPress: () => onEditLocation?.(booking),
role: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
},
{
label: "Add Guests",
icon: "person.badge.plus" as const,
onPress: () => onAddGuests?.(booking),
role: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
},
// After Event Section
{
label: "View Recordings",
icon: "video" as const,
onPress: () => onViewRecordings?.(booking),
role: "default" as const,
visible:
actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
},
{
label: "Meeting Session Details",
icon: "info.circle" as const,
onPress: () => onMeetingSessionDetails?.(booking),
role: "default" as const,
visible:
actions.meetingSessionDetails.visible &&
actions.meetingSessionDetails.enabled &&
!!onMeetingSessionDetails,
},
{
label: "Mark as No-Show",
icon: "eye.slash" as const,
onPress: () => onMarkNoShow?.(booking),
role: "default" as const,
visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
},
// Other Actions
{
label: "Report Booking",
icon: "flag" as const,
onPress: () => onReportBooking?.(booking),
role: "destructive" as const,
visible: !!onReportBooking,
},
{
label: "Cancel Event",
icon: "xmark.circle" as const,
onPress: () => onCancelBooking?.(booking),
role: "destructive" as const,
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-cal-border bg-cal-bg">
<Pressable
onPress={() => onPress(group)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
className="active:bg-cal-bg-secondary"
>
{/* Date and Time */}
<View className="mb-2 flex-row flex-wrap items-center">
<Text className="text-sm font-medium text-cal-text">{formattedDate}</Text>
<Text className="ml-2 text-sm text-cal-text-secondary">{formattedTimeRange}</Text>
</View>
{/* Badges Row */}
<View className="mb-3 flex-row flex-wrap items-center gap-2">
{/* Recurring Badge */}
<View className="rounded border border-cal-border bg-gray-500 px-2 py-0.5">
<Text className="text-xs font-medium text-white">
{group.remainingCount} {group.remainingCount === 1 ? "event" : "events"} remaining
</Text>
</View>
{/* Unconfirmed Badge */}
{group.hasUnconfirmed && (
<View className="rounded bg-cal-accent-warning px-2 py-0.5">
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
</View>
)}
</View>
{/* Recurrence Pattern Text (for unconfirmed recurring) */}
{group.hasUnconfirmed && group.recurrenceText && (
<Text className="mb-2 text-sm text-cal-text-secondary">{group.recurrenceText}</Text>
)}
{/* Title */}
<Text
className={`mb-2 text-lg font-medium leading-5 text-cal-text ${isCancelled || isRejected ? "line-through" : ""}`}
numberOfLines={2}
>
{booking.title}
</Text>
{/* Description */}
{booking.description ? (
<Text className="mb-2 text-sm leading-5 text-cal-text-secondary" numberOfLines={1}>
"{booking.description}"
</Text>
) : null}
{/* Host and Attendees */}
{hostAndAttendeesDisplay ? (
<View className="mb-2 flex-row items-center">
<Text className="text-sm text-cal-text">{hostAndAttendeesDisplay}</Text>
</View>
) : 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-cal-accent">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
) : null}
</Pressable>
{/* Action Buttons */}
<View
className="flex-row flex-wrap items-center justify-end"
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
>
{/* Confirm All / Reject All for unconfirmed recurring */}
{group.hasUnconfirmed && onRejectAll && (
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-cal-border bg-cal-bg"
style={{
paddingHorizontal: 12,
height: 32,
opacity: isProcessing ? 0.5 : 1,
}}
disabled={isProcessing}
onPress={(e) => {
e.stopPropagation();
onRejectAll(group);
}}
>
<Ionicons name="close" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-cal-text-emphasis">Reject all</Text>
</TouchableOpacity>
)}
{group.hasUnconfirmed && onConfirmAll && (
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg bg-black"
style={{
paddingHorizontal: 12,
height: 32,
opacity: isProcessing ? 0.5 : 1,
}}
disabled={isProcessing}
onPress={(e) => {
e.stopPropagation();
onConfirmAll(group);
}}
>
<Ionicons name="checkmark" size={16} color="#FFFFFF" />
<Text className="ml-1 text-sm font-medium text-white">Confirm all</Text>
</TouchableOpacity>
)}
{/* Cancel All Remaining */}
{onCancelAllRemaining && group.remainingCount > 0 && !group.hasUnconfirmed && (
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border bg-cal-bg"
style={{
paddingHorizontal: 12,
height: 32,
opacity: isProcessing ? 0.5 : 1,
borderColor: "#800020",
}}
disabled={isProcessing}
onPress={(e) => {
e.stopPropagation();
onCancelAllRemaining(group);
}}
>
<Ionicons name="close-circle-outline" size={16} color="#800020" />
<Text className="ml-1 text-sm font-medium" style={{ color: "#800020" }}>
Cancel all remaining
</Text>
</TouchableOpacity>
)}
{/* iOS Context Menu */}
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{contextMenuActions.map((action) => (
<Button
key={action.label}
systemImage={action.icon}
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,365 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Pressable, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text as UIText } from "@/components/ui/text";
import type { Booking } from "@/services/calcom";
import type { RecurringBookingGroup } from "@/utils/bookings-utils";
import { formatDate, formatTime, getHostAndAttendeesDisplay } from "@/utils/bookings-utils";
import { getMeetingInfo } from "@/utils/meetings-utils";
import { SvgImage } from "@/components/SvgImage";
import { showErrorAlert } from "@/utils/alerts";
import { Linking } from "react-native";
import { getBookingActions } from "@/utils/booking-actions";
export interface RecurringBookingListItemProps {
group: RecurringBookingGroup;
userEmail?: string;
isConfirmingAll?: boolean;
isDecliningAll?: boolean;
isCancellingAll?: boolean;
onPress: (group: RecurringBookingGroup) => void;
onConfirmAll?: (group: RecurringBookingGroup) => void;
onRejectAll?: (group: RecurringBookingGroup) => void;
onCancelAllRemaining?: (group: RecurringBookingGroup) => void;
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;
}
export const RecurringBookingListItem: React.FC<RecurringBookingListItemProps> = ({
group,
userEmail,
isConfirmingAll = false,
isDecliningAll = false,
isCancellingAll = false,
onPress,
onConfirmAll,
onRejectAll,
onCancelAllRemaining,
onReschedule,
onEditLocation,
onAddGuests,
onViewRecordings,
onMeetingSessionDetails,
onMarkNoShow,
onReportBooking,
onCancelBooking,
}) => {
const booking = group.firstUpcoming;
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isUpcoming = new Date(endTime) >= new Date();
const isCancelled = booking.status?.toLowerCase() === "cancelled";
const isRejected = booking.status?.toLowerCase() === "rejected";
const isPending = booking.status?.toLowerCase() === "pending" || booking.requiresConfirmation;
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
const meetingInfo = getMeetingInfo(booking.location);
const formattedDate = formatDate(startTime, isUpcoming);
const formattedTimeRange = `${formatTime(startTime)} - ${formatTime(endTime)}`;
const isProcessing = isConfirmingAll || isDecliningAll || isCancellingAll;
const insets = useSafeAreaInsets();
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
const actions = React.useMemo(() => {
return getBookingActions({
booking,
eventType: undefined,
currentUserId: undefined,
currentUserEmail: userEmail,
isOnline: true,
});
}, [booking, userEmail]);
type DropdownAction = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
variant?: "default" | "destructive";
};
const allActions: (DropdownAction & { visible: boolean })[] = [
{
label: "Reschedule Booking",
icon: "calendar-outline",
onPress: () => onReschedule?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
},
{
label: "Edit Location",
icon: "location-outline",
onPress: () => onEditLocation?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
},
{
label: "Add Guests",
icon: "person-add-outline",
onPress: () => onAddGuests?.(booking),
variant: "default" as const,
visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
},
{
label: "View Recordings",
icon: "videocam-outline",
onPress: () => onViewRecordings?.(booking),
variant: "default" as const,
visible:
actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
},
{
label: "Meeting Session Details",
icon: "information-circle-outline",
onPress: () => onMeetingSessionDetails?.(booking),
variant: "default" as const,
visible:
actions.meetingSessionDetails.visible &&
actions.meetingSessionDetails.enabled &&
!!onMeetingSessionDetails,
},
{
label: "Mark as No-Show",
icon: "eye-off-outline",
onPress: () => onMarkNoShow?.(booking),
variant: "default" as const,
visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
},
{
label: "Report Booking",
icon: "flag-outline",
onPress: () => onReportBooking?.(booking),
variant: "destructive" as const,
visible: !!onReportBooking,
},
{
label: "Cancel Event",
icon: "close-circle-outline",
onPress: () => onCancelBooking?.(booking),
variant: "destructive" as const,
visible: isUpcoming && !isCancelled && !!onCancelBooking,
},
];
const visibleActions = allActions.filter((action) => action.visible);
const destructiveStartIndex = visibleActions.findIndex(
(action) => action.variant === "destructive"
);
return (
<View className="border-b border-cal-border bg-cal-bg">
<Pressable
onPress={() => onPress(group)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
className="active:bg-cal-bg-secondary"
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
>
{/* Date and Time */}
<View className="mb-2 flex-row flex-wrap items-center">
<Text className="text-sm font-medium text-cal-text">{formattedDate}</Text>
<Text className="ml-2 text-sm text-cal-text-secondary">{formattedTimeRange}</Text>
</View>
{/* Badges Row */}
<View className="mb-3 flex-row flex-wrap items-center gap-2">
{/* Recurring Badge */}
<View className="rounded border border-cal-border bg-gray-500 px-2 py-0.5">
<Text className="text-xs font-medium text-white">
{group.remainingCount} {group.remainingCount === 1 ? "event" : "events"} remaining
</Text>
</View>
{/* Unconfirmed Badge */}
{group.hasUnconfirmed && (
<View className="rounded bg-cal-accent-warning px-2 py-0.5">
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
</View>
)}
</View>
{/* Recurrence Pattern Text (for unconfirmed recurring) */}
{group.hasUnconfirmed && group.recurrenceText && (
<Text className="mb-2 text-sm text-cal-text-secondary">{group.recurrenceText}</Text>
)}
{/* Title */}
<Text
className={`mb-2 text-lg font-medium leading-5 text-cal-text ${isCancelled || isRejected ? "line-through" : ""}`}
numberOfLines={2}
>
{booking.title}
</Text>
{/* Description */}
{booking.description ? (
<Text className="mb-2 text-sm leading-5 text-cal-text-secondary" numberOfLines={1}>
"{booking.description}"
</Text>
) : null}
{/* Host and Attendees */}
{hostAndAttendeesDisplay ? (
<View className="mb-2 flex-row items-center">
<Text className="text-sm text-cal-text">{hostAndAttendeesDisplay}</Text>
</View>
) : 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-cal-accent">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
) : null}
</Pressable>
{/* Action Buttons */}
<View
className="flex-row flex-wrap items-center justify-end"
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
>
{/* Confirm All / Reject All for unconfirmed recurring */}
{group.hasUnconfirmed && onRejectAll && (
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-cal-border bg-cal-bg"
style={{
paddingHorizontal: 12,
height: 32,
opacity: isProcessing ? 0.5 : 1,
}}
disabled={isProcessing}
onPress={(e) => {
e.stopPropagation();
onRejectAll(group);
}}
>
<Ionicons name="close" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-cal-text-emphasis">Reject all</Text>
</TouchableOpacity>
)}
{group.hasUnconfirmed && onConfirmAll && (
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg bg-black"
style={{
paddingHorizontal: 12,
height: 32,
opacity: isProcessing ? 0.5 : 1,
}}
disabled={isProcessing}
onPress={(e) => {
e.stopPropagation();
onConfirmAll(group);
}}
>
<Ionicons name="checkmark" size={16} color="#FFFFFF" />
<Text className="ml-1 text-sm font-medium text-white">Confirm all</Text>
</TouchableOpacity>
)}
{/* Cancel All Remaining */}
{onCancelAllRemaining && group.remainingCount > 0 && !group.hasUnconfirmed && (
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border bg-cal-bg"
style={{
paddingHorizontal: 12,
height: 32,
opacity: isProcessing ? 0.5 : 1,
borderColor: "#800020",
}}
disabled={isProcessing}
onPress={(e) => {
e.stopPropagation();
onCancelAllRemaining(group);
}}
>
<Ionicons name="close-circle-outline" size={16} color="#800020" />
<Text className="ml-1 text-sm font-medium" style={{ color: "#800020" }}>
Cancel all remaining
</Text>
</TouchableOpacity>
)}
{/* Dropdown Menu - only show when there are visible actions */}
{visibleActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent insets={contentInsets} sideOffset={8} className="w-52" align="end">
{visibleActions.map((action, index) => (
<React.Fragment key={action.label}>
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#800020" : "#374151"}
style={{ marginRight: 8 }}
/>
<UIText className={action.variant === "destructive" ? "text-destructive" : ""}>
{action.label}
</UIText>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</View>
</View>
);
};
@@ -6,11 +6,8 @@ export interface BookingListItemProps {
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;
@@ -12,6 +12,7 @@ import {
View,
} from "react-native";
import { BookingListItem } from "@/components/booking-list-item/BookingListItem";
import { RecurringBookingListItem } from "@/components/booking-list-item/RecurringBookingListItem";
import { BookingModals } from "@/components/booking-modals/BookingModals";
import { EmptyScreen } from "@/components/EmptyScreen";
import { LoadingSpinner } from "@/components/LoadingSpinner";
@@ -37,11 +38,12 @@ import {
useRescheduleBooking,
} from "@/hooks";
import type { Booking, EventType } from "@/services/calcom";
import type { ListItem } from "@/utils/bookings-utils";
import type { ListItem, RecurringBookingGroup } from "@/utils/bookings-utils";
import {
filterByEventType,
getEmptyStateContent,
groupBookingsByMonth,
groupRecurringBookings,
searchBookings,
} from "@/utils/bookings-utils";
import { offlineAwareRefresh } from "@/utils/network";
@@ -126,7 +128,6 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
handleSubmitCancel,
handleCloseCancelModal,
selectedBooking,
setSelectedBooking,
handleBookingPress,
handleCancelBooking,
handleInlineConfirm,
@@ -263,7 +264,302 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
return filtered;
}, [bookings, searchQuery, selectedEventTypeId]);
const [showBookingActionsModal, setShowBookingActionsModal] = React.useState(false);
// Generate list items based on filter type
const listItems = useMemo<ListItem[]>(() => {
if (activeFilter === "recurring") {
// For recurring filter, group bookings by recurringBookingUid
const groups = groupRecurringBookings(filteredBookings);
return groups.map((group) => ({
type: "recurringGroup" as const,
group,
key: `recurring-${group.recurringBookingUid}`,
}));
}
if (activeFilter === "unconfirmed") {
// For unconfirmed filter, group recurring bookings but keep non-recurring separate
const recurringBookings: Booking[] = [];
const nonRecurringBookings: Booking[] = [];
filteredBookings.forEach((booking) => {
if (booking.recurringBookingUid) {
recurringBookings.push(booking);
} else {
nonRecurringBookings.push(booking);
}
});
// Group recurring bookings
const recurringGroups = groupRecurringBookings(recurringBookings);
const recurringItems: ListItem[] = recurringGroups.map((group) => ({
type: "recurringGroup" as const,
group,
key: `recurring-${group.recurringBookingUid}`,
}));
// Keep non-recurring bookings with month grouping
const nonRecurringItems = groupBookingsByMonth(nonRecurringBookings);
// Combine: recurring groups first, then non-recurring
return [...recurringItems, ...nonRecurringItems];
}
// For other filters, use month grouping
return groupBookingsByMonth(filteredBookings);
}, [filteredBookings, activeFilter]);
// State for bulk action loading
const [isCancellingAll, setIsCancellingAll] = React.useState(false);
const [isConfirmingAll, setIsConfirmingAll] = React.useState(false);
const [isDecliningAll, setIsDecliningAll] = React.useState(false);
// Android dialog state for Cancel All
const [showCancelAllDialog, setShowCancelAllDialog] = React.useState(false);
const [cancelAllGroup, setCancelAllGroup] = React.useState<RecurringBookingGroup | null>(null);
const [cancelAllReason, setCancelAllReason] = React.useState("");
// Android dialog state for Reject All
const [showRejectAllDialog, setShowRejectAllDialog] = React.useState(false);
const [rejectAllGroup, setRejectAllGroup] = React.useState<RecurringBookingGroup | null>(null);
const [rejectAllReason, setRejectAllReason] = React.useState("");
// Handle recurring group press - navigate to first upcoming booking
const handleRecurringGroupPress = React.useCallback(
(group: RecurringBookingGroup) => {
handleBookingPress(group.firstUpcoming);
},
[handleBookingPress]
);
// Cancel all remaining bookings in a recurring series
const handleCancelAllRemaining = React.useCallback(
async (group: RecurringBookingGroup) => {
// For Android, open dialog with reason input
if (Platform.OS === "android") {
setCancelAllGroup(group);
setCancelAllReason("");
setShowCancelAllDialog(true);
return;
}
// For iOS, use Alert.prompt
Alert.alert(
"Cancel All Remaining",
`Are you sure you want to cancel all ${group.remainingCount} remaining bookings in this series?`,
[
{ text: "No", style: "cancel" },
{
text: "Yes, Cancel All",
style: "destructive",
onPress: () => {
Alert.prompt(
"Cancellation Reason",
"Please provide a reason for cancelling these bookings:",
[
{ text: "Cancel", style: "cancel" },
{
text: "Cancel All",
style: "destructive",
onPress: (reason?: string) => {
const cancellationReason = reason?.trim() || "Cancelled all remaining";
setIsCancellingAll(true);
cancelBookingMutation(
{
uid: group.recurringBookingUid,
reason: cancellationReason,
},
{
onSuccess: () => {
Alert.alert("Success", "All remaining bookings have been cancelled.");
setIsCancellingAll(false);
},
onError: () => {
Alert.alert("Error", "Failed to cancel bookings. Please try again.");
setIsCancellingAll(false);
},
}
);
},
},
],
"plain-text",
"",
"default"
);
},
},
]
);
},
[cancelBookingMutation]
);
// Confirm all unconfirmed bookings in a recurring series
const handleConfirmAll = React.useCallback(
async (group: RecurringBookingGroup) => {
const unconfirmedBookings = group.bookings.filter(
(b) =>
b.status?.toLowerCase() === "pending" ||
b.status?.toLowerCase() === "requires_confirmation" ||
b.requiresConfirmation
);
if (unconfirmedBookings.length === 0) {
Alert.alert("Info", "No unconfirmed bookings to confirm.");
return;
}
Alert.alert(
"Confirm All",
`Are you sure you want to confirm ${unconfirmedBookings.length} unconfirmed bookings?`,
[
{ text: "No", style: "cancel" },
{
text: "Yes, Confirm All",
onPress: async () => {
setIsConfirmingAll(true);
let successCount = 0;
let errorCount = 0;
for (const booking of unconfirmedBookings) {
try {
const success = await new Promise<boolean>((resolve, _reject) => {
confirmBookingMutation(
{ uid: booking.uid },
{
onSuccess: () => {
resolve(true);
},
onError: () => {
resolve(false); // Continue even on error
},
}
);
});
if (success) {
successCount++;
} else {
errorCount++;
}
} catch {
errorCount++;
}
}
setIsConfirmingAll(false);
if (errorCount > 0) {
Alert.alert(
"Partial Success",
`Confirmed ${successCount} bookings. Failed to confirm ${errorCount}.`
);
} else {
Alert.alert("Success", `All ${successCount} bookings have been confirmed.`);
}
},
},
]
);
},
[confirmBookingMutation]
);
// Reject all unconfirmed bookings in a recurring series
const handleRejectAll = React.useCallback(
async (group: RecurringBookingGroup) => {
const unconfirmedBookings = group.bookings.filter(
(b) =>
b.status?.toLowerCase() === "pending" ||
b.status?.toLowerCase() === "requires_confirmation" ||
b.requiresConfirmation
);
if (unconfirmedBookings.length === 0) {
Alert.alert("Info", "No unconfirmed bookings to reject.");
return;
}
// For Android, open dialog with reason input
if (Platform.OS === "android") {
setRejectAllGroup(group);
setRejectAllReason("");
setShowRejectAllDialog(true);
return;
}
// For iOS, use Alert.prompt
Alert.alert(
"Reject All",
`Are you sure you want to reject ${unconfirmedBookings.length} unconfirmed bookings?`,
[
{ text: "No", style: "cancel" },
{
text: "Yes, Reject All",
style: "destructive",
onPress: () => {
Alert.prompt(
"Rejection Reason",
"Optionally provide a reason for rejecting (press OK to skip):",
[
{ text: "Cancel", style: "cancel" },
{
text: "OK",
onPress: async (reason?: string) => {
setIsDecliningAll(true);
let successCount = 0;
let errorCount = 0;
for (const booking of unconfirmedBookings) {
try {
const success = await new Promise<boolean>((resolve, _reject) => {
declineBookingMutation(
{
uid: booking.uid,
reason: reason || undefined,
},
{
onSuccess: () => {
resolve(true);
},
onError: () => {
resolve(false);
},
}
);
});
if (success) {
successCount++;
} else {
errorCount++;
}
} catch {
errorCount++;
}
}
setIsDecliningAll(false);
if (errorCount > 0) {
Alert.alert(
"Partial Success",
`Rejected ${successCount} bookings. Failed to reject ${errorCount}.`
);
} else {
Alert.alert("Success", `All ${successCount} bookings have been rejected.`);
}
},
},
],
"plain-text",
"",
"default"
);
},
},
]
);
},
[declineBookingMutation]
);
const renderBookingItem = ({ item }: { item: Booking }) => {
return (
@@ -273,17 +569,8 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
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 - now use screen navigation
onReschedule={handleNavigateToReschedule}
onEditLocation={handleNavigateToEditLocation}
onAddGuests={handleNavigateToAddGuests}
@@ -306,6 +593,31 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
</View>
);
}
if (item.type === "recurringGroup") {
return (
<RecurringBookingListItem
group={item.group}
userEmail={userInfo?.email}
isConfirmingAll={isConfirmingAll}
isDecliningAll={isDecliningAll}
isCancellingAll={isCancellingAll}
onPress={handleRecurringGroupPress}
onConfirmAll={handleConfirmAll}
onRejectAll={handleRejectAll}
onCancelAllRemaining={handleCancelAllRemaining}
onReschedule={handleNavigateToReschedule}
onEditLocation={handleNavigateToEditLocation}
onAddGuests={handleNavigateToAddGuests}
onViewRecordings={handleNavigateToViewRecordings}
onMeetingSessionDetails={handleNavigateToMeetingSessionDetails}
onMarkNoShow={handleNavigateToMarkNoShow}
onReportBooking={() => {
Alert.alert("Report Booking", "Report booking functionality is not yet available");
}}
onCancelBooking={handleCancelBooking}
/>
);
}
return renderBookingItem({ item: item.booking });
};
@@ -397,7 +709,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
<Activity mode={showList ? "visible" : "hidden"}>
<Activity mode={iosStyle ? "visible" : "hidden"}>
<FlatList
data={groupBookingsByMonth(filteredBookings)}
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
@@ -414,7 +726,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
<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)}
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
@@ -448,44 +760,16 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
selectedEventTypeId={selectedEventTypeId}
onFilterClose={() => setShowFilterModal?.(false)}
onEventTypeSelect={onEventTypeChange}
showBookingActionsModal={showBookingActionsModal}
showBookingActionsModal={false}
selectedBooking={selectedBooking}
onActionsClose={() => setShowBookingActionsModal(false)}
onReschedule={() => {
// Navigate to reschedule screen instead of opening modal
if (selectedBooking) {
setShowBookingActionsModal(false);
handleNavigateToReschedule(selectedBooking);
}
}}
onCancel={() => {
if (selectedBooking) handleCancelBooking(selectedBooking);
}}
onEditLocation={(booking) => {
// Navigate to edit location screen instead of opening modal
setShowBookingActionsModal(false);
handleNavigateToEditLocation(booking);
}}
onAddGuests={(booking) => {
// Navigate to add guests screen instead of opening modal
setShowBookingActionsModal(false);
handleNavigateToAddGuests(booking);
}}
onViewRecordings={(booking) => {
// Navigate to view recordings screen instead of opening modal
setShowBookingActionsModal(false);
handleNavigateToViewRecordings(booking);
}}
onMeetingSessionDetails={(booking) => {
// Navigate to meeting session details screen instead of opening modal
setShowBookingActionsModal(false);
handleNavigateToMeetingSessionDetails(booking);
}}
onMarkNoShow={(booking) => {
// Navigate to mark no show screen instead of opening modal
setShowBookingActionsModal(false);
handleNavigateToMarkNoShow(booking);
}}
onActionsClose={() => {}}
onReschedule={() => {}}
onCancel={() => {}}
onEditLocation={() => {}}
onAddGuests={() => {}}
onViewRecordings={() => {}}
onMeetingSessionDetails={() => {}}
onMarkNoShow={() => {}}
/>
{/* Cancel Event AlertDialog for Android */}
@@ -531,6 +815,174 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
</AlertDialogContent>
</AlertDialog>
)}
{/* Android: Cancel All Remaining Dialog */}
{Platform.OS === "android" && showCancelAllDialog && cancelAllGroup && (
<AlertDialog open={showCancelAllDialog} onOpenChange={setShowCancelAllDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel All Remaining</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel all {cancelAllGroup.remainingCount} remaining
bookings in this series?
</AlertDialogDescription>
</AlertDialogHeader>
<View className="py-4">
<Text className="mb-2 text-sm font-medium text-cal-text">
Cancellation Reason (required)
</Text>
<TextInput
className="rounded-lg border border-cal-border bg-cal-bg px-3 py-2 text-base text-cal-text"
placeholder="Please provide a reason for cancelling"
value={cancelAllReason}
onChangeText={setCancelAllReason}
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
<AlertDialogFooter>
<AlertDialogCancel
onPress={() => {
setShowCancelAllDialog(false);
setCancelAllGroup(null);
setCancelAllReason("");
}}
>
<UIText>Nevermind</UIText>
</AlertDialogCancel>
<AlertDialogAction
onPress={() => {
const reason = cancelAllReason.trim() || "Cancelled all remaining";
setShowCancelAllDialog(false);
setIsCancellingAll(true);
cancelBookingMutation(
{ uid: cancelAllGroup.recurringBookingUid, reason },
{
onSuccess: () => {
Alert.alert("Success", "All remaining bookings have been cancelled.");
setIsCancellingAll(false);
setCancelAllGroup(null);
setCancelAllReason("");
},
onError: () => {
Alert.alert("Error", "Failed to cancel bookings. Please try again.");
setIsCancellingAll(false);
},
}
);
}}
>
<UIText className="text-white">Cancel All</UIText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Android: Reject All Dialog */}
{Platform.OS === "android" && showRejectAllDialog && rejectAllGroup && (
<AlertDialog open={showRejectAllDialog} onOpenChange={setShowRejectAllDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject All</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reject all unconfirmed bookings in this series?
</AlertDialogDescription>
</AlertDialogHeader>
<View className="py-4">
<Text className="mb-2 text-sm font-medium text-cal-text">
Rejection Reason (optional)
</Text>
<TextInput
className="rounded-lg border border-cal-border bg-cal-bg px-3 py-2 text-base text-cal-text"
placeholder="Optionally provide a reason for rejecting"
value={rejectAllReason}
onChangeText={setRejectAllReason}
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
<AlertDialogFooter>
<AlertDialogCancel
onPress={() => {
setShowRejectAllDialog(false);
setRejectAllGroup(null);
setRejectAllReason("");
}}
>
<UIText>Nevermind</UIText>
</AlertDialogCancel>
<AlertDialogAction
onPress={async () => {
const reason = rejectAllReason.trim() || undefined;
setShowRejectAllDialog(false);
setIsDecliningAll(true);
const unconfirmedBookings = rejectAllGroup.bookings.filter(
(b) =>
b.status?.toLowerCase() === "pending" ||
b.status?.toLowerCase() === "requires_confirmation" ||
b.requiresConfirmation
);
let successCount = 0;
let errorCount = 0;
for (const booking of unconfirmedBookings) {
try {
const success = await new Promise<boolean>((resolve, _reject) => {
declineBookingMutation(
{ uid: booking.uid, reason },
{
onSuccess: () => {
resolve(true);
},
onError: () => {
resolve(false);
},
}
);
});
if (success) {
successCount++;
} else {
errorCount++;
}
} catch {
errorCount++;
}
}
setIsDecliningAll(false);
setRejectAllGroup(null);
setRejectAllReason("");
if (errorCount > 0) {
Alert.alert(
"Partial Success",
`Rejected ${successCount} bookings. Failed to reject ${errorCount}.`
);
} else {
Alert.alert("Success", `All ${successCount} bookings have been rejected.`);
}
}}
>
<UIText className="text-white">Reject All</UIText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
};
@@ -1,120 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text } from "@/components/ui/text";
import {
DurationBadge,
EventTypeDescription,
EventTypeTitle,
PriceAndConfirmationBadges,
} from "./EventTypeListItemParts";
import type { EventTypeListItemProps } from "./types";
import { useEventTypeListItemData } from "./useEventTypeListItemData";
export const EventTypeListItem = ({
item,
index,
filteredEventTypes,
copiedEventTypeId: _copiedEventTypeId,
handleEventTypePress,
handleEventTypeLongPress: _handleEventTypeLongPress,
handleCopyLink,
handlePreview,
onEdit,
onDuplicate,
onDelete,
}: EventTypeListItemProps) => {
const { formattedDuration, normalizedDescription, hasPrice, formattedPrice } =
useEventTypeListItemData(item);
const isLast = index === filteredEventTypes.length - 1;
const insets = useSafeAreaInsets();
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
return (
<View className={`bg-cal-bg ${!isLast ? "border-b border-cal-border" : ""}`}>
<View
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
className="flex-row items-center justify-between"
>
<Pressable
onPress={() => handleEventTypePress(item)}
className="mr-4 flex-1"
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
>
<EventTypeTitle title={item.title} />
<EventTypeDescription normalizedDescription={normalizedDescription} />
<DurationBadge formattedDuration={formattedDuration} />
<PriceAndConfirmationBadges
hasPrice={hasPrice}
formattedPrice={formattedPrice}
requiresConfirmation={item.requiresConfirmation}
/>
</Pressable>
{/* Dropdown Menu - Single Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 36, height: 36 }}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent insets={contentInsets} sideOffset={8} className="w-44" align="end">
{/* Preview & Copy Actions */}
<DropdownMenuItem onPress={() => handlePreview(item)}>
<Ionicons name="open-outline" size={18} color="#374151" style={{ marginRight: 8 }} />
<Text>Preview</Text>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => handleCopyLink(item)}>
<Ionicons name="link-outline" size={18} color="#374151" style={{ marginRight: 8 }} />
<Text>Copy link</Text>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Edit & Duplicate Actions */}
<DropdownMenuItem onPress={() => onEdit?.(item)}>
<Ionicons
name="pencil-outline"
size={18}
color="#374151"
style={{ marginRight: 8 }}
/>
<Text>Edit</Text>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => onDuplicate?.(item)}>
<Ionicons name="copy-outline" size={18} color="#374151" style={{ marginRight: 8 }} />
<Text>Duplicate</Text>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Delete Action - Destructive */}
<DropdownMenuItem variant="destructive" onPress={() => onDelete?.(item)}>
<Ionicons name="trash-outline" size={18} color="#DC2626" style={{ marginRight: 8 }} />
<Text className="text-destructive">Delete</Text>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
</View>
);
};
@@ -3,12 +3,7 @@ import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import type React from "react";
import { Pressable, View } from "react-native";
import {
DurationBadge,
EventTypeDescription,
EventTypeTitle,
PriceAndConfirmationBadges,
} from "./EventTypeListItemParts";
import { EventTypeBadges, EventTypeDescription, EventTypeTitle } from "./EventTypeListItemParts";
import type { EventTypeListItemProps } from "./types";
import { useEventTypeListItemData } from "./useEventTypeListItemData";
@@ -16,9 +11,7 @@ export const EventTypeListItem = ({
item,
index,
filteredEventTypes,
copiedEventTypeId: _copiedEventTypeId,
handleEventTypePress,
handleEventTypeLongPress: _handleEventTypeLongPress,
handleCopyLink,
handlePreview,
onEdit,
@@ -92,25 +85,35 @@ export const EventTypeListItem = ({
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<View className="flex-shrink-1 flex-row items-center justify-between">
<View className="flex-row items-center justify-between">
<Pressable
onPress={() => handleEventTypePress(item)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
className="flex-grow"
style={{
paddingTop: 16,
paddingBottom: 22,
paddingLeft: 16,
flex: 1,
marginRight: 12,
}}
>
<View className="mr-4 flex-1">
<EventTypeTitle title={item.title} />
<EventTypeDescription normalizedDescription={normalizedDescription} />
<DurationBadge formattedDuration={formattedDuration} />
<PriceAndConfirmationBadges
hasPrice={hasPrice}
formattedPrice={formattedPrice}
requiresConfirmation={item.requiresConfirmation}
/>
</View>
<EventTypeTitle
title={item.title}
username={item.users?.[0]?.username}
slug={item.slug}
/>
<EventTypeDescription normalizedDescription={normalizedDescription} />
<EventTypeBadges
formattedDuration={formattedDuration}
hidden={item.hidden}
seats={item.seats}
hasPrice={hasPrice}
formattedPrice={formattedPrice}
confirmationPolicy={item.confirmationPolicy}
recurrence={item.recurrence}
/>
</Pressable>
<View style={{ paddingRight: 16 }}>
<View style={{ paddingRight: 16, flexShrink: 0 }}>
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
@@ -1,11 +1,15 @@
import { TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
DurationBadge,
EventTypeActions,
EventTypeDescription,
EventTypeTitle,
PriceAndConfirmationBadges,
} from "./EventTypeListItemParts";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text } from "@/components/ui/text";
import { EventTypeBadges, EventTypeDescription, EventTypeTitle } from "./EventTypeListItemParts";
import type { EventTypeListItemProps } from "./types";
import { useEventTypeListItemData } from "./useEventTypeListItemData";
@@ -13,42 +17,100 @@ export const EventTypeListItem = ({
item,
index,
filteredEventTypes,
copiedEventTypeId,
handleEventTypePress,
handleEventTypeLongPress,
handleCopyLink,
handlePreview,
onEdit,
onDuplicate,
onDelete,
}: EventTypeListItemProps) => {
const { formattedDuration, normalizedDescription, hasPrice, formattedPrice } =
useEventTypeListItemData(item);
const isLast = index === filteredEventTypes.length - 1;
const insets = useSafeAreaInsets();
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
return (
<TouchableOpacity
className={`bg-cal-bg active:bg-cal-bg-secondary ${!isLast ? "border-b border-cal-border" : ""}`}
onPress={() => handleEventTypePress(item)}
onLongPress={() => handleEventTypeLongPress(item)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<View className="mr-4 flex-1">
<EventTypeTitle title={item.title} />
<View className={`bg-cal-bg ${!isLast ? "border-b border-cal-border" : ""}`}>
<View
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
className="flex-row items-center justify-between"
>
<Pressable
onPress={() => handleEventTypePress(item)}
style={{ flex: 1, marginRight: 12 }}
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
>
<EventTypeTitle
title={item.title}
username={item.users?.[0]?.username}
slug={item.slug}
/>
<EventTypeDescription normalizedDescription={normalizedDescription} />
<DurationBadge formattedDuration={formattedDuration} />
<PriceAndConfirmationBadges
<EventTypeBadges
formattedDuration={formattedDuration}
hidden={item.hidden}
seats={item.seats}
hasPrice={hasPrice}
formattedPrice={formattedPrice}
requiresConfirmation={item.requiresConfirmation}
confirmationPolicy={item.confirmationPolicy}
recurrence={item.recurrence}
/>
</View>
<EventTypeActions
item={item}
copiedEventTypeId={copiedEventTypeId}
handleCopyLink={handleCopyLink}
handlePreview={handlePreview}
handleEventTypeLongPress={handleEventTypeLongPress}
/>
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 36, height: 36, flexShrink: 0 }}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent insets={contentInsets} sideOffset={8} className="w-44" align="end">
<DropdownMenuItem onPress={() => handlePreview(item)}>
<Ionicons name="open-outline" size={18} color="#374151" style={{ marginRight: 8 }} />
<Text>Preview</Text>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => handleCopyLink(item)}>
<Ionicons name="link-outline" size={18} color="#374151" style={{ marginRight: 8 }} />
<Text>Copy link</Text>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onPress={() => onEdit?.(item)}>
<Ionicons
name="pencil-outline"
size={18}
color="#374151"
style={{ marginRight: 8 }}
/>
<Text>Edit</Text>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => onDuplicate?.(item)}>
<Ionicons name="copy-outline" size={18} color="#374151" style={{ marginRight: 8 }} />
<Text>Duplicate</Text>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onPress={() => onDelete?.(item)}>
<Ionicons name="trash-outline" size={18} color="#800020" style={{ marginRight: 8 }} />
<Text className="text-destructive">Delete</Text>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
</TouchableOpacity>
</View>
);
};
@@ -6,12 +6,16 @@ import type { EventType } from "@/services/types/event-types.types";
interface EventTypeTitleProps {
title: string;
username?: string;
slug: string;
}
export function EventTypeTitle({ title }: EventTypeTitleProps) {
export function EventTypeTitle({ title, username, slug }: EventTypeTitleProps) {
const linkText = username ? `/${username}/${slug}` : `/${slug}`;
return (
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-cal-text">{title}</Text>
<View className="mb-1 flex-row flex-wrap items-baseline">
<Text className="text-base font-semibold text-cal-text">{title}</Text>
<Text className="ml-1 text-sm text-cal-text-secondary">{linkText}</Text>
</View>
);
}
@@ -29,39 +33,133 @@ export function EventTypeDescription({ normalizedDescription }: EventTypeDescrip
);
}
interface DurationBadgeProps {
interface EventTypeLinkProps {
username?: string;
slug: string;
}
export function EventTypeLink({ username, slug }: EventTypeLinkProps) {
const linkText = username ? `/${username}/${slug}` : `/${slug}`;
return <Text className="mb-1 mt-0.5 text-sm text-cal-text-secondary">{linkText}</Text>;
}
interface EventTypeBadgesProps {
formattedDuration: string;
}
export function DurationBadge({ formattedDuration }: DurationBadgeProps) {
return (
<View className="mt-2 flex-row items-center self-start rounded-lg border border-cal-border bg-cal-border px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000000" />
<Text className="ml-1.5 text-xs font-semibold text-cal-brand-black">{formattedDuration}</Text>
</View>
);
}
interface PriceAndConfirmationBadgesProps {
hidden?: boolean;
seats?: {
disabled?: boolean;
seatsPerTimeSlot?: number;
showAttendeeInfo?: boolean;
showAvailabilityCount?: boolean;
};
hasPrice: boolean;
formattedPrice: string | null;
requiresConfirmation?: boolean;
confirmationPolicy?: EventType["confirmationPolicy"];
recurrence?: {
disabled?: boolean;
interval?: number;
occurrences?: number;
frequency?: string;
} | null;
}
export function PriceAndConfirmationBadges({
export function EventTypeBadges({
formattedDuration,
hidden,
seats,
hasPrice,
formattedPrice,
requiresConfirmation,
}: PriceAndConfirmationBadgesProps) {
if (!hasPrice && !requiresConfirmation) return null;
confirmationPolicy,
recurrence,
}: EventTypeBadgesProps) {
const hasSeats = seats && !seats.disabled && seats.seatsPerTimeSlot && seats.seatsPerTimeSlot > 0;
const requiresConfirmation =
confirmationPolicy &&
!("disabled" in confirmationPolicy && confirmationPolicy.disabled) &&
"type" in confirmationPolicy &&
confirmationPolicy.type === "always";
const hasRecurrence = recurrence && !recurrence.disabled && recurrence.occurrences;
// Render nothing if we seemingly have no badges (Duration is usually always present though)
// But checking just in case
if (
!formattedDuration &&
!hidden &&
!hasSeats &&
(!hasPrice || !formattedPrice) &&
!hasRecurrence &&
!requiresConfirmation
) {
return null;
}
return (
<View className="mt-2 flex-row items-center gap-3">
<View className="mt-2 flex-row flex-wrap items-center gap-2" style={{ width: "100%" }}>
{/* Duration Badge */}
<View
className="rounded-md border border-cal-border bg-cal-border"
style={{ height: 24, paddingHorizontal: 8, flexDirection: "row", alignItems: "center" }}
>
<Ionicons name="time-outline" size={14} color="#000000" />
<Text className="ml-1.5 text-xs font-semibold text-cal-brand-black">
{formattedDuration}
</Text>
</View>
{/* Hidden Badge */}
{hidden ? (
<View
className="rounded-md border border-cal-border bg-cal-border"
style={{ height: 24, paddingHorizontal: 8, flexDirection: "row", alignItems: "center" }}
>
<Ionicons name="eye-off-outline" size={14} color="#000000" />
<Text className="ml-1.5 text-xs font-medium text-cal-brand-black">Hidden</Text>
</View>
) : null}
{/* Seats Badge */}
{hasSeats ? (
<View
className="rounded-md border border-cal-border bg-cal-border"
style={{ height: 24, paddingHorizontal: 8, flexDirection: "row", alignItems: "center" }}
>
<Ionicons name="people-outline" size={14} color="#000000" />
<Text className="ml-1.5 text-xs font-medium text-cal-brand-black">
{seats.seatsPerTimeSlot} seats
</Text>
</View>
) : null}
{/* Price Badge */}
{hasPrice && formattedPrice ? (
<Text className="text-sm font-medium text-cal-accent-success">{formattedPrice}</Text>
) : null}
{/* Repeats Badge */}
{hasRecurrence ? (
<View
className="rounded-md border border-cal-border bg-cal-border"
style={{ height: 24, paddingHorizontal: 8, flexDirection: "row", alignItems: "center" }}
>
<Ionicons name="repeat-outline" size={14} color="#000000" />
<Text className="ml-1.5 text-xs font-medium text-cal-brand-black">
{recurrence.occurrences} times
</Text>
</View>
) : null}
{/* Requires Confirmation Badge */}
{requiresConfirmation ? (
<View className="rounded bg-cal-accent-warning px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
<View
className="rounded-md border border-cal-border bg-cal-border"
style={{ height: 24, paddingHorizontal: 8, flexDirection: "row", alignItems: "center" }}
>
<Ionicons name="checkmark-circle-outline" size={14} color="#000000" />
<Text className="ml-1.5 text-xs font-medium text-cal-brand-black">
Requires confirmation
</Text>
</View>
) : null}
</View>
@@ -4,9 +4,7 @@ export interface EventTypeListItemProps {
item: EventType;
index: number;
filteredEventTypes: EventType[];
copiedEventTypeId: number | null;
handleEventTypePress: (item: EventType) => void;
handleEventTypeLongPress: (item: EventType) => void;
handleCopyLink: (item: EventType) => void;
handlePreview: (item: EventType) => void;
onEdit?: (item: EventType) => void;
@@ -18,8 +18,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAddGuests } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
import { safeLogError } from "@/utils/safeLogger";
export interface AddGuestsScreenProps {
@@ -51,7 +51,9 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [guests, setGuests] = useState<{ email: string; name?: string }[]>([]);
const [isSaving, setIsSaving] = useState(false);
// Use React Query mutation for automatic cache invalidation
const { mutate: addGuestsMutation, isPending: isSaving } = useAddGuests();
// Notify parent of saving state changes
useEffect(() => {
@@ -92,7 +94,7 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
[guests]
);
const handleSubmit = useCallback(async () => {
const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
if (guests.length === 0) {
@@ -100,17 +102,24 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
return;
}
setIsSaving(true);
try {
await CalComAPIService.addGuests(booking.uid, guests);
Alert.alert("Success", "Guests added successfully", [{ text: "OK", onPress: onSuccess }]);
setIsSaving(false);
} catch (error) {
safeLogError("[AddGuestsScreen] Failed to add guests:", error);
Alert.alert("Error", "Failed to add guests. Please try again.");
setIsSaving(false);
}
}, [booking, guests, onSuccess, isSaving]);
addGuestsMutation(
{
uid: booking.uid,
guests,
},
{
onSuccess: () => {
Alert.alert("Success", "Guests added successfully", [
{ text: "OK", onPress: onSuccess },
]);
},
onError: (error) => {
safeLogError("[AddGuestsScreen] Failed to add guests:", error);
Alert.alert("Error", "Failed to add guests. Please try again.");
},
}
);
}, [booking, guests, onSuccess, isSaving, addGuestsMutation]);
// Expose submit function to parent via ref (same pattern as senior's actionHandlersRef)
useImperativeHandle(
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { Activity, useMemo, useState } from "react";
import {
ActionSheetIOS,
Alert,
FlatList,
Platform,
@@ -106,75 +105,6 @@ export function AvailabilityListScreen({
onSearchChange(query);
};
const handleScheduleLongPress = (schedule: Schedule) => {
if (Platform.OS !== "ios") {
// Fallback for non-iOS platforms (Android Alert supports max 3 buttons)
const options: {
text: string;
onPress: () => void;
style?: "destructive" | "cancel" | "default";
}[] = [];
if (!schedule.isDefault) {
options.push({
text: "Set as default",
onPress: () => handleSetAsDefault(schedule),
});
}
options.push(
{ text: "Duplicate", onPress: () => handleDuplicate(schedule) },
{
text: "Delete",
style: "destructive" as const,
onPress: () => handleDelete(schedule),
}
);
// Android Alert automatically adds cancel, so we don't need to include it explicitly
Alert.alert(schedule.name, "", options);
return;
}
const options = ["Cancel"];
if (!schedule.isDefault) {
options.push("Set as default");
}
options.push("Duplicate", "Delete");
const destructiveButtonIndex = options.length - 1; // Delete button
const cancelButtonIndex = 0;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex,
cancelButtonIndex,
title: schedule.name,
},
(buttonIndex) => {
if (buttonIndex === cancelButtonIndex) {
return;
}
if (!schedule.isDefault) {
// Options: ["Cancel", "Set as default", "Duplicate", "Delete"]
if (buttonIndex === 1) {
handleSetAsDefault(schedule);
} else if (buttonIndex === 2) {
handleDuplicate(schedule);
} else if (buttonIndex === 3) {
handleDelete(schedule);
}
} else {
// Options: ["Cancel", "Duplicate", "Delete"]
if (buttonIndex === 1) {
handleDuplicate(schedule);
} else if (buttonIndex === 2) {
handleDelete(schedule);
}
}
}
);
};
const handleSetAsDefault = (schedule: Schedule) => {
setAsDefaultMutation(schedule.id, {
onError: () => {
@@ -433,9 +363,6 @@ export function AvailabilityListScreen({
item={item}
index={index}
handleSchedulePress={handleSchedulePress}
handleScheduleLongPress={handleScheduleLongPress}
setSelectedSchedule={setSelectedSchedule}
setShowActionsModal={setShowActionsModal}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
onSetAsDefault={handleSetAsDefault}
@@ -1,846 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text as UIText } from "@/components/ui/text";
import { SvgImage } from "@/components/SvgImage";
import { useAuth } from "@/contexts/AuthContext";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { openInAppBrowser } from "@/utils/browser";
import { defaultLocations, getDefaultLocationIconUrl } from "@/utils/defaultLocations";
import { formatAppIdToDisplayName } from "@/utils/formatters";
import { getAppIconUrl } from "@/utils/getAppIconUrl";
// Empty actions result for when no booking is loaded
const EMPTY_ACTIONS: BookingActionsResult = {
reschedule: { visible: false, enabled: false },
rescheduleRequest: { visible: false, enabled: false },
cancel: { visible: false, enabled: false },
changeLocation: { visible: false, enabled: false },
addGuests: { visible: false, enabled: false },
viewRecordings: { visible: false, enabled: false },
meetingSessionDetails: { visible: false, enabled: false },
markNoShow: { visible: false, enabled: false },
};
// Format date: "Tuesday, November 25, 2025"
const formatDateFull = (dateString: string): string => {
if (!dateString) return "";
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
// Format time: "9:40pm - 10:00pm"
const formatTime12Hour = (dateString: string): string => {
if (!dateString) return "";
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return "";
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? "pm" : "am";
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const minStr = minutes.toString().padStart(2, "0");
return `${hour12}:${minStr}${period}`;
};
// Get user's local timezone for display
const getTimezone = (): string => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return timeZone || "";
};
// Get initials from a name(e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS")
const getInitials = (name: string): string => {
if (!name) return "";
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return "";
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
// Get first letter of first name and first letter of last name
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
};
// Get location provider info
const getLocationProvider = (location: string | undefined, metadata?: Record<string, unknown>) => {
// Check metadata for videoCallUrl first
const videoCallUrl = metadata?.videoCallUrl;
const locationToCheck = videoCallUrl || location;
if (!locationToCheck) return null;
// Check if it's a video call URL
if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) {
// Try to detect provider from URL
if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) {
const iconUrl = getAppIconUrl("daily_video", "cal-video");
return {
label: "Cal Video",
iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg",
url: locationToCheck,
};
}
// Check for other video providers by URL pattern
const videoProviders = [
{ pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" },
{
pattern: /meet\.google\.com/,
label: "Google Meet",
type: "google_video",
appId: "google-meet",
},
{
pattern: /teams\.microsoft\.com/,
label: "Microsoft Teams",
type: "office365_video",
appId: "msteams",
},
];
for (const provider of videoProviders) {
if (provider.pattern.test(locationToCheck)) {
const iconUrl = getAppIconUrl(provider.type, provider.appId);
return {
label: provider.label,
iconUrl: iconUrl,
url: locationToCheck,
};
}
}
// Generic link meeting
const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg";
return {
label: "Link Meeting",
iconUrl: linkIconUrl,
url: locationToCheck,
};
}
// Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video")
if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) {
const appId = locationToCheck.replace("integrations:", "");
const iconUrl = getAppIconUrl("", appId);
if (iconUrl) {
return {
label: formatAppIdToDisplayName(appId),
iconUrl: iconUrl,
url: null,
};
}
}
// Check if it's a default location type
const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck);
if (defaultLocation) {
return {
label: defaultLocation.label,
iconUrl: defaultLocation.iconUrl,
url: null,
};
}
// Fallback: return as plain text location
return {
label: locationToCheck as string,
iconUrl: null,
url: null,
};
};
export interface BookingDetailScreenProps {
uid: string;
onActionsReady?: (handlers: {
openRescheduleModal: () => void;
openEditLocationModal: () => void;
openAddGuestsModal: () => void;
openViewRecordingsModal: () => void;
openMeetingSessionDetailsModal: () => void;
openMarkNoShowModal: () => void;
handleCancelBooking: () => void;
}) => void;
}
export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps) {
const router = useRouter();
const { userInfo } = useAuth();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [cancellationReason, setCancellationReason] = useState("");
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
// Compute actions using centralized gating
const actions = useMemo(() => {
if (!booking) return EMPTY_ACTIONS;
return getBookingActions({
booking,
eventType: undefined,
currentUserId: userInfo?.id,
currentUserEmail: userInfo?.email,
isOnline: true,
});
}, [booking, userInfo?.id, userInfo?.email]);
// Cancel booking handler
const performCancelBooking = useCallback(
async (reason: string) => {
if (!booking) return;
setIsCancelling(true);
try {
await CalComAPIService.cancelBooking(booking.uid, reason);
Alert.alert("Success", "Booking cancelled successfully", [
{
text: "OK",
onPress: () => router.back(),
},
]);
setIsCancelling(false);
} catch (err) {
console.error("Failed to cancel booking");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
console.debug("[BookingDetailScreen.android] cancelBooking failed", {
message,
});
}
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
setIsCancelling(false);
}
},
[booking, router]
);
const handleCancelBooking = useCallback(() => {
if (!booking) return;
setCancellationReason("");
setShowCancelDialog(true);
}, [booking]);
const handleConfirmCancel = useCallback(() => {
const reason = cancellationReason.trim() || "Cancelled by host";
setShowCancelDialog(false);
setCancellationReason("");
performCancelBooking(reason);
}, [cancellationReason, performCancelBooking]);
const handleCloseCancelDialog = useCallback(() => {
setShowCancelDialog(false);
setCancellationReason("");
}, []);
// Navigate to reschedule screen
const openRescheduleModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/reschedule",
params: { uid: booking.uid },
});
}, [booking, router]);
// Navigate to edit location screen
const openEditLocationModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/edit-location",
params: { uid: booking.uid },
});
}, [booking, router]);
// Navigate to add guests screen
const openAddGuestsModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/add-guests",
params: { uid: booking.uid },
});
}, [booking, router]);
// Navigate to mark no show screen
const openMarkNoShowModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/mark-no-show",
params: { uid: booking.uid },
});
}, [booking, router]);
// Navigate to view recordings screen
const openViewRecordingsModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/view-recordings",
params: { uid: booking.uid },
});
}, [booking, router]);
// Navigate to meeting session details screen
const openMeetingSessionDetailsModal = useCallback(() => {
if (!booking) return;
router.push({
pathname: "/meeting-session-details",
params: { uid: booking.uid },
});
}, [booking, router]);
const handleReportBooking = useCallback(() => {
Alert.alert("Report Booking", "Report booking functionality is not yet available");
}, []);
const fetchBooking = useCallback(async () => {
setLoading(true);
setError(null);
let bookingData: Booking | null = null;
let fetchError: Error | null = null;
try {
bookingData = await CalComAPIService.getBookingByUid(uid);
} catch (err) {
fetchError = err instanceof Error ? err : new Error(String(err));
}
if (bookingData) {
if (__DEV__) {
const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
const attendeeCount = bookingData.attendees?.length ?? 0;
console.debug("[BookingDetailScreen.android] booking fetched", {
uid: bookingData.uid,
status: bookingData.status,
hostCount,
attendeeCount,
hasRecurringEventId: Boolean(bookingData.recurringEventId),
});
}
setBooking(bookingData);
setLoading(false);
} else {
console.error("Error fetching booking");
if (__DEV__ && fetchError) {
console.debug("[BookingDetailScreen.android] fetchBooking failed", {
message: fetchError.message,
stack: fetchError.stack,
});
}
setError("Failed to load booking. Please try again.");
if (__DEV__) {
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} else {
router.back();
}
setLoading(false);
}
}, [uid, router]);
useEffect(() => {
if (uid) {
fetchBooking();
} else {
setLoading(false);
setError("Invalid booking ID");
}
}, [uid, fetchBooking]);
// Expose action handlers to parent component
useEffect(() => {
if (booking && onActionsReady) {
onActionsReady({
openRescheduleModal,
openEditLocationModal,
openAddGuestsModal,
openViewRecordingsModal,
openMeetingSessionDetailsModal,
openMarkNoShowModal,
handleCancelBooking,
});
}
}, [
booking,
onActionsReady,
openRescheduleModal,
openEditLocationModal,
openAddGuestsModal,
openViewRecordingsModal,
openMeetingSessionDetailsModal,
handleCancelBooking,
openMarkNoShowModal,
]);
const handleJoinMeeting = () => {
if (!booking?.location) return;
const provider = getLocationProvider(booking.location);
if (provider?.url) {
openInAppBrowser(provider.url, "meeting link");
}
};
// Build dropdown menu actions
const dropdownActions = useMemo(() => {
if (!booking) return [];
const _startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isPast = new Date(endTime) < new Date();
const isCancelled = booking.status.toLowerCase() === "cancelled";
const isPending = booking.status.toLowerCase() === "pending";
const isUpcoming = !isPast;
type DropdownAction = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
variant?: "default" | "destructive";
visible: boolean;
};
const allActions: DropdownAction[] = [
// Edit Event Section
{
label: "Reschedule Booking",
icon: "calendar-outline",
onPress: openRescheduleModal,
variant: "default",
visible: isUpcoming && !isCancelled && !isPending,
},
{
label: "Edit Location",
icon: "location-outline",
onPress: openEditLocationModal,
variant: "default",
visible: isUpcoming && !isCancelled && !isPending,
},
{
label: "Add Guests",
icon: "person-add-outline",
onPress: openAddGuestsModal,
variant: "default",
visible: isUpcoming && !isCancelled && !isPending,
},
// After Event Section
{
label: "View Recordings",
icon: "videocam-outline",
onPress: openViewRecordingsModal,
variant: "default",
visible: actions.viewRecordings.visible && actions.viewRecordings.enabled,
},
{
label: "Meeting Session Details",
icon: "information-circle-outline",
onPress: openMeetingSessionDetailsModal,
variant: "default",
visible: actions.meetingSessionDetails.visible && actions.meetingSessionDetails.enabled,
},
{
label: "Mark as No-Show",
icon: "eye-off-outline",
onPress: openMarkNoShowModal,
variant: "default",
visible: actions.markNoShow.visible && actions.markNoShow.enabled,
},
// Other Actions
{
label: "Report Booking",
icon: "flag-outline",
onPress: handleReportBooking,
variant: "destructive",
visible: true,
},
{
label: "Cancel Event",
icon: "close-circle-outline",
onPress: handleCancelBooking,
variant: "destructive",
visible: isUpcoming && !isCancelled,
},
];
return allActions.filter((action) => action.visible);
}, [
booking,
actions,
openRescheduleModal,
openEditLocationModal,
openAddGuestsModal,
openViewRecordingsModal,
openMeetingSessionDetailsModal,
openMarkNoShowModal,
handleReportBooking,
handleCancelBooking,
]);
// Find the index where destructive actions start
const destructiveStartIndex = dropdownActions.findIndex(
(action) => action.variant === "destructive"
);
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-gray-500">Loading booking...</Text>
</View>
);
}
if (error || !booking) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa] p-5">
<Ionicons name="alert-circle" size={64} color="#800020" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{error || "Booking not found"}
</Text>
<AppPressable className="mt-6 rounded-lg bg-black px-6 py-3" onPress={() => router.back()}>
<Text className="text-base font-semibold text-white">Go Back</Text>
</AppPressable>
</View>
);
}
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const dateFormatted = formatDateFull(startTime);
const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
const timezone = getTimezone();
const locationProvider = getLocationProvider(booking.location, booking.responses);
return (
<>
{/* Header with DropdownMenu */}
<Stack.Screen
options={{
headerRight: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable className="h-10 w-10 items-center justify-center rounded-full">
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={contentInsets}
sideOffset={8}
className="w-52"
align="end"
>
{dropdownActions.map((action, index) => (
<React.Fragment key={action.label}>
{/* Add separator before destructive actions */}
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#DC2626" : "#374151"}
style={{ marginRight: 8 }}
/>
<UIText
className={action.variant === "destructive" ? "text-destructive" : ""}
>
{action.label}
</UIText>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
),
}}
/>
<View className="flex-1 bg-[#f8f9fa]">
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, paddingBottom: 100 }}>
{/* Title */}
<View className="mb-3">
<Text className="mb-2 text-2xl font-semibold text-[#333]">{booking.title}</Text>
<Text className="text-base text-[#666]">
{dateFormatted} {timeFormatted} ({timezone})
</Text>
</View>
{/* Who Section */}
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Who</Text>
{/* Show host from user field or hosts array */}
{booking.user || (booking.hosts && booking.hosts.length > 0) ? (
<View className="mb-4">
{booking.user ? (
<View className="flex-row items-start">
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(booking.user.name)}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{booking.user.name}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
<Text className="text-sm text-[#666]">{booking.user.email}</Text>
</View>
</View>
) : booking.hosts && booking.hosts.length > 0 ? (
booking.hosts.map((host, hostIndex) => (
<View
key={host.email ?? host.name}
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}
>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(host.name || "Host")}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{host.name || "Host"}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
{host.email && <Text className="text-sm text-[#666]">{host.email}</Text>}
</View>
</View>
))
) : null}
</View>
) : null}
{booking.attendees && booking.attendees.length > 0 ? (
<View>
{booking.attendees.map((attendee, index) => {
const isNoShow =
(attendee as { noShow?: boolean; absent?: boolean }).noShow === true ||
(attendee as { noShow?: boolean; absent?: boolean }).absent === true;
return (
<View
key={attendee.email}
className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}
>
<View
className={`mr-3 h-12 w-12 items-center justify-center rounded-full ${
isNoShow ? "bg-[#DC2626]" : "bg-black"
}`}
>
<Text className="text-base font-semibold text-white">
{getInitials(attendee.name)}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row items-center">
<Text
className={`text-base font-medium ${
isNoShow ? "text-[#DC2626]" : "text-[#333]"
}`}
>
{attendee.name}
</Text>
{isNoShow && (
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-2 py-0.5">
<Ionicons name="eye-off" size={12} color="#DC2626" />
<Text className="ml-1 text-xs font-medium text-[#DC2626]">
No-show
</Text>
</View>
)}
</View>
<Text className={`text-sm ${isNoShow ? "text-[#DC2626]" : "text-[#666]"}`}>
{attendee.email}
</Text>
</View>
</View>
);
})}
</View>
) : null}
</View>
{/* Where Section */}
{locationProvider ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
{locationProvider.url ? (
<AppPressable
onPress={handleJoinMeeting}
className="flex-row flex-wrap items-center"
>
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
{locationProvider.url}
</Text>
</AppPressable>
) : (
<View className="flex-row items-center">
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
</View>
)}
</View>
) : null}
{/* Recurring Event Section */}
{booking.recurringEventId ||
(booking as { recurringBookingUid?: string }).recurringBookingUid ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="text-base font-medium text-[#666]">
This is part of a recurring event
</Text>
</View>
) : null}
{/* Description Section */}
{booking.description ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Description</Text>
<Text className="text-base leading-6 text-[#666]">{booking.description}</Text>
</View>
) : null}
{/* Join Meeting Button */}
{locationProvider?.url ? (
<AppPressable
onPress={handleJoinMeeting}
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
>
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base font-semibold text-white">
Join {locationProvider.label}
</Text>
</AppPressable>
) : null}
</ScrollView>
{/* Cancelling overlay */}
{isCancelling && (
<View className="absolute inset-0 items-center justify-center bg-black/50">
<View className="rounded-2xl bg-white px-8 py-6">
<ActivityIndicator size="large" color="#000" />
<Text className="mt-3 text-base font-medium text-gray-700">
Cancelling booking...
</Text>
</View>
</View>
)}
</View>
{/* Cancel Event AlertDialog */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader className="items-start">
<AlertDialogTitle>
<UIText className="text-left text-lg font-semibold">Cancel event</UIText>
</AlertDialogTitle>
<AlertDialogDescription>
<UIText className="text-left text-sm text-muted-foreground">
Cancellation reason will be shared with guests
</UIText>
</AlertDialogDescription>
</AlertDialogHeader>
{/* Reason Input */}
<View>
<UIText className="mb-2 text-sm font-medium">Reason for cancellation</UIText>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
placeholder="Why are you cancelling?"
placeholderTextColor="#9CA3AF"
value={cancellationReason}
onChangeText={setCancellationReason}
autoFocus
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
<AlertDialogFooter>
<AlertDialogCancel onPress={handleCloseCancelDialog}>
<UIText>Nevermind</UIText>
</AlertDialogCancel>
<AlertDialogAction onPress={handleConfirmCancel}>
<UIText className="text-white">Cancel event</UIText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,10 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, ScrollView, Text, View } from "react-native";
import { ActivityIndicator, Alert, RefreshControl, ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { useCancelBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
// Format date for iOS Calendar style: "Thursday, 25 Dec 2025"
@@ -64,7 +65,30 @@ const calculateDuration = (startDateString: string, endDateString: string): numb
};
export interface BookingDetailScreenProps {
uid: string;
/**
* The booking data to display. When null/undefined, shows loading or error state.
*/
booking: Booking | null | undefined;
/**
* Whether the booking data is currently being fetched.
*/
isLoading: boolean;
/**
* Error that occurred while fetching the booking, if any.
*/
error: Error | null;
/**
* Function to refetch the booking data. Used for pull-to-refresh.
*/
refetch: () => void;
/**
* Whether a refetch is currently in progress. Used for RefreshControl.
*/
isRefetching?: boolean;
/**
* Callback to expose internal action handlers to parent component.
* Used by iOS header menu to trigger actions like reschedule.
*/
onActionsReady?: (handlers: {
openRescheduleModal: () => void;
openEditLocationModal: () => void;
@@ -77,46 +101,51 @@ export interface BookingDetailScreenProps {
}
export function BookingDetailScreen({
uid,
booking,
isLoading,
error,
refetch,
isRefetching = false,
onActionsReady,
}: BookingDetailScreenProps): React.JSX.Element {
const router = useRouter();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const [participantsExpanded, setParticipantsExpanded] = useState(true);
// Cancel booking mutation
const cancelBookingMutation = useCancelBooking();
const isCancelling = cancelBookingMutation.isPending;
const performCancelBooking = useCallback(
async (reason: string) => {
(reason: string) => {
if (!booking) return;
setIsCancelling(true);
try {
await CalComAPIService.cancelBooking(booking.uid, reason);
Alert.alert("Success", "Booking cancelled successfully", [
{
text: "OK",
onPress: () => router.back(),
cancelBookingMutation.mutate(
{ uid: booking.uid, reason },
{
onSuccess: () => {
Alert.alert("Success", "Booking cancelled successfully", [
{
text: "OK",
onPress: () => router.back(),
},
]);
},
onError: (err) => {
console.error("Failed to cancel booking");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
console.debug("[BookingDetailScreen.ios] cancelBooking failed", {
message,
});
}
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
},
]);
setIsCancelling(false);
} catch (err) {
console.error("Failed to cancel booking");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
console.debug("[BookingDetailScreen.ios] cancelBooking failed", {
message,
});
}
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
setIsCancelling(false);
}
);
},
[booking, router]
[booking, router, cancelBookingMutation]
);
const handleCancelBooking = useCallback(() => {
@@ -198,61 +227,6 @@ export function BookingDetailScreen({
});
}, [booking, router]);
const fetchBooking = useCallback(async () => {
setLoading(true);
setError(null);
let bookingData: Booking | null = null;
let fetchError: Error | null = null;
try {
bookingData = await CalComAPIService.getBookingByUid(uid);
} catch (err) {
fetchError = err instanceof Error ? err : new Error(String(err));
}
if (bookingData) {
if (__DEV__) {
const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
const attendeeCount = bookingData.attendees?.length ?? 0;
console.debug("[BookingDetailScreen.ios] booking fetched", {
uid: bookingData.uid,
status: bookingData.status,
hostCount,
attendeeCount,
hasRecurringEventId: Boolean(bookingData.recurringEventId),
});
}
setBooking(bookingData);
setLoading(false);
} else {
console.error("Error fetching booking");
if (__DEV__ && fetchError) {
console.debug("[BookingDetailScreen.ios] fetchBooking failed", {
message: fetchError.message,
stack: fetchError.stack,
});
}
setError("Failed to load booking. Please try again.");
if (__DEV__) {
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} else {
router.back();
}
setLoading(false);
}
}, [uid, router]);
useEffect(() => {
if (uid) {
fetchBooking();
} else {
setLoading(false);
setError("Invalid booking ID");
}
}, [uid, fetchBooking]);
useEffect(() => {
if (booking && onActionsReady) {
onActionsReady({
@@ -277,7 +251,7 @@ export function BookingDetailScreen({
openMarkNoShowModal,
]);
if (loading) {
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-[#f2f2f7]">
<ActivityIndicator size="large" color="#000000" />
@@ -287,11 +261,12 @@ export function BookingDetailScreen({
}
if (error || !booking) {
const errorMessage = error?.message || "Booking not found";
return (
<View className="flex-1 items-center justify-center bg-[#f2f2f7] 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">
{error || "Booking not found"}
{errorMessage}
</Text>
<AppPressable className="mt-6 rounded-lg bg-black px-6 py-3" onPress={() => router.back()}>
<Text className="text-base font-semibold text-white">Go Back</Text>
@@ -361,6 +336,7 @@ export function BookingDetailScreen({
paddingBottom: insets.bottom + 100,
}}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
>
{/* Title Section - iOS Calendar Style */}
<View className="mb-8">
@@ -1,12 +1,43 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Alert, Platform, ScrollView, Text, View } from "react-native";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Alert,
Platform,
Pressable,
RefreshControl,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text as UIText } from "@/components/ui/text";
import { AppPressable } from "@/components/AppPressable";
import { BookingActionsModal } from "@/components/BookingActionsModal";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { SvgImage } from "@/components/SvgImage";
import { useAuth } from "@/contexts/AuthContext";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { useCancelBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { openInAppBrowser } from "@/utils/browser";
@@ -159,7 +190,26 @@ const getLocationProvider = (location: string | undefined, metadata?: Record<str
};
export interface BookingDetailScreenProps {
uid: string;
/**
* The booking data to display. When null/undefined, shows loading or error state.
*/
booking: Booking | null | undefined;
/**
* Whether the booking data is currently being fetched.
*/
isLoading: boolean;
/**
* Error that occurred while fetching the booking, if any.
*/
error: Error | null;
/**
* Function to refetch the booking data. Used for pull-to-refresh.
*/
refetch: () => void;
/**
* Whether a refetch is currently in progress. Used for RefreshControl.
*/
isRefetching?: boolean;
/**
* Callback to expose internal action handlers to parent component.
* Used by iOS header menu to trigger actions like reschedule.
@@ -175,15 +225,32 @@ export interface BookingDetailScreenProps {
}) => void;
}
export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps) {
export function BookingDetailScreen({
booking,
isLoading,
error,
refetch,
isRefetching = false,
onActionsReady,
}: BookingDetailScreenProps) {
const router = useRouter();
const { userInfo } = useAuth();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [showActionsModal, setShowActionsModal] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [cancellationReason, setCancellationReason] = useState("");
// Cancel booking mutation
const cancelBookingMutation = useCancelBooking();
const isCancelling = cancelBookingMutation.isPending;
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
// Compute actions using centralized gating
const actions = useMemo(() => {
@@ -199,71 +266,90 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
// Cancel booking handler (needs to be defined before useEffect that exposes it)
const performCancelBooking = useCallback(
async (reason: string) => {
(reason: string) => {
if (!booking) return;
setIsCancelling(true);
setShowActionsModal(false);
try {
await CalComAPIService.cancelBooking(booking.uid, reason);
Alert.alert("Success", "Booking cancelled successfully", [
{
text: "OK",
onPress: () => router.back(),
cancelBookingMutation.mutate(
{ uid: booking.uid, reason },
{
onSuccess: () => {
Alert.alert("Success", "Booking cancelled successfully", [
{
text: "OK",
onPress: () => router.back(),
},
]);
},
onError: (err) => {
console.error("Failed to cancel booking");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
console.debug("[BookingDetailScreen] cancelBooking failed", { message });
}
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
},
]);
setIsCancelling(false);
} catch (error) {
console.error("Failed to cancel booking");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
console.debug("[BookingDetailScreen] cancelBooking failed", { message });
}
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
setIsCancelling(false);
}
);
},
[booking, router]
[booking, router, cancelBookingMutation]
);
const handleCancelBooking = useCallback(() => {
if (!booking) return;
Alert.alert("Cancel Booking", `Are you sure you want to cancel "${booking.title}"?`, [
{ text: "No", style: "cancel" },
{
text: "Yes, Cancel",
style: "destructive",
onPress: () => {
// Prompt for cancellation reason (iOS only supports Alert.prompt)
if (Platform.OS === "ios") {
Alert.prompt(
"Cancellation Reason",
"Please provide a reason for cancelling this booking:",
[
{ text: "Cancel", style: "cancel" },
{
text: "Cancel Booking",
style: "destructive",
onPress: (reason?: string) => {
performCancelBooking(reason?.trim() || "Cancelled by host");
if (Platform.OS === "android") {
setCancellationReason("");
setShowCancelDialog(true);
} else {
Alert.alert("Cancel Booking", `Are you sure you want to cancel "${booking.title}"?`, [
{ text: "No", style: "cancel" },
{
text: "Yes, Cancel",
style: "destructive",
onPress: () => {
if (Platform.OS === "ios") {
Alert.prompt(
"Cancellation Reason",
"Please provide a reason for cancelling this booking:",
[
{ text: "Cancel", style: "cancel" },
{
text: "Cancel Booking",
style: "destructive",
onPress: (reason?: string) => {
performCancelBooking(reason?.trim() || "Cancelled by host");
},
},
},
],
"plain-text",
"",
"default"
);
} else {
// For Android, just cancel with default reason
performCancelBooking("Cancelled by host");
}
],
"plain-text",
"",
"default"
);
} else {
performCancelBooking("Cancelled by host");
}
},
},
},
]);
]);
}
}, [booking, performCancelBooking]);
const handleConfirmCancel = useCallback(() => {
const reason = cancellationReason.trim() || "Cancelled by host";
setShowCancelDialog(false);
setCancellationReason("");
performCancelBooking(reason);
}, [cancellationReason, performCancelBooking]);
const handleCloseCancelDialog = useCallback(() => {
setShowCancelDialog(false);
setCancellationReason("");
}, []);
const handleReportBooking = useCallback(() => {
Alert.alert("Report Booking", "Report booking functionality is not yet available");
}, []);
// Navigate to reschedule screen (same pattern as senior's - navigate to screen in same folder)
const openRescheduleModal = useCallback(() => {
if (!booking) return;
@@ -318,61 +404,6 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
});
}, [booking, router]);
const fetchBooking = useCallback(async () => {
setLoading(true);
setError(null);
let bookingData: Booking | null = null;
let fetchError: Error | null = null;
try {
bookingData = await CalComAPIService.getBookingByUid(uid);
} catch (err) {
fetchError = err instanceof Error ? err : new Error(String(err));
}
if (bookingData) {
if (__DEV__) {
const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
const attendeeCount = bookingData.attendees?.length ?? 0;
console.debug("[BookingDetailScreen] booking fetched", {
uid: bookingData.uid,
status: bookingData.status,
hostCount,
attendeeCount,
hasRecurringEventId: Boolean(bookingData.recurringEventId),
});
}
setBooking(bookingData);
setLoading(false);
} else {
console.error("Error fetching booking");
if (__DEV__ && fetchError) {
console.debug("[BookingDetailScreen] fetchBooking failed", {
message: fetchError.message,
stack: fetchError.stack,
});
}
setError("Failed to load booking. Please try again.");
if (__DEV__) {
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} else {
router.back();
}
setLoading(false);
}
}, [uid, router]);
useEffect(() => {
if (uid) {
fetchBooking();
} else {
setLoading(false);
setError("Invalid booking ID");
}
}, [uid, fetchBooking]);
// Expose action handlers to parent component (for iOS header menu)
useEffect(() => {
if (booking && onActionsReady) {
@@ -407,7 +438,89 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
}
};
if (loading) {
const dropdownActions = useMemo(() => {
if (!booking) return [];
type DropdownAction = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
visible: boolean;
variant?: "default" | "destructive";
};
const allActions: DropdownAction[] = [
{
label: "Reschedule Booking",
icon: "calendar-outline",
onPress: openRescheduleModal,
visible: actions.reschedule.visible && actions.reschedule.enabled,
},
{
label: "Edit Location",
icon: "location-outline",
onPress: openEditLocationModal,
visible: actions.changeLocation.visible && actions.changeLocation.enabled,
},
{
label: "Add Guests",
icon: "people-outline",
onPress: openAddGuestsModal,
visible: actions.addGuests.visible && actions.addGuests.enabled,
},
{
label: "View Recordings",
icon: "videocam-outline",
onPress: openViewRecordingsModal,
visible: actions.viewRecordings.visible && actions.viewRecordings.enabled,
},
{
label: "Meeting Session Details",
icon: "information-circle-outline",
onPress: openMeetingSessionDetailsModal,
visible: actions.meetingSessionDetails.visible && actions.meetingSessionDetails.enabled,
},
{
label: "Mark as No-Show",
icon: "eye-off-outline",
onPress: openMarkNoShowModal,
visible: actions.markNoShow.visible && actions.markNoShow.enabled,
},
{
label: "Report Booking",
icon: "flag-outline",
onPress: handleReportBooking,
visible: true,
variant: "destructive",
},
{
label: "Cancel Event",
icon: "close-circle-outline",
onPress: handleCancelBooking,
visible: actions.cancel.visible && actions.cancel.enabled,
variant: "destructive",
},
];
return allActions.filter((action) => action.visible);
}, [
booking,
actions,
openRescheduleModal,
openEditLocationModal,
openAddGuestsModal,
openViewRecordingsModal,
openMeetingSessionDetailsModal,
openMarkNoShowModal,
handleReportBooking,
handleCancelBooking,
]);
const destructiveStartIndex = dropdownActions.findIndex(
(action) => action.variant === "destructive"
);
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
@@ -417,11 +530,12 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
}
if (error || !booking) {
const errorMessage = error?.message || "Booking not found";
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa] p-5">
<Ionicons name="alert-circle" size={64} color="#800020" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{error || "Booking not found"}
{errorMessage}
</Text>
<AppPressable className="mt-6 rounded-lg bg-black px-6 py-3" onPress={() => router.back()}>
<Text className="text-base font-semibold text-white">Go Back</Text>
@@ -444,18 +558,53 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
<Stack.Screen
options={{
headerRight: () => (
<AppPressable
className="h-10 w-10 items-center justify-center rounded-full"
onPress={() => setShowActionsModal(true)}
>
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</AppPressable>
<HeaderButtonWrapper side="right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable className="h-10 w-10 items-center justify-center rounded-full">
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={contentInsets}
sideOffset={8}
className="w-52"
align="end"
>
{dropdownActions.map((action, index) => (
<React.Fragment key={action.label}>
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#800020" : "#374151"}
style={{ marginRight: 8 }}
/>
<UIText
className={action.variant === "destructive" ? "text-destructive" : ""}
>
{action.label}
</UIText>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
</HeaderButtonWrapper>
),
}}
/>
)}
<View className="flex-1 bg-[#]">
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, paddingBottom: 100 }}>
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 100 }}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
>
{/* Title */}
<View className="mb-3">
<Text className="mb-2 text-2xl font-semibold text-[#333]">{booking.title}</Text>
@@ -529,7 +678,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
>
<View
className={`mr-3 h-12 w-12 items-center justify-center rounded-full ${
isNoShow ? "bg-[#DC2626]" : "bg-black"
isNoShow ? "bg-[#800020]" : "bg-black"
}`}
>
<Text className="text-base font-semibold text-white">
@@ -540,21 +689,21 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
<View className="mb-1 flex-row items-center">
<Text
className={`text-base font-medium ${
isNoShow ? "text-[#DC2626]" : "text-[#333]"
isNoShow ? "text-[#800020]" : "text-[#333]"
}`}
>
{attendee.name}
</Text>
{isNoShow && (
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-2 py-0.5">
<Ionicons name="eye-off" size={12} color="#DC2626" />
<Text className="ml-1 text-xs font-medium text-[#DC2626]">
<Ionicons name="eye-off" size={12} color="#800020" />
<Text className="ml-1 text-xs font-medium text-[#800020]">
No-show
</Text>
</View>
)}
</View>
<Text className={`text-sm ${isNoShow ? "text-[#DC2626]" : "text-[#666]"}`}>
<Text className={`text-sm ${isNoShow ? "text-[#800020]" : "text-[#666]"}`}>
{attendee.email}
</Text>
</View>
@@ -672,6 +821,47 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
</View>
)}
</View>
{/* Cancel Event AlertDialog (Android only) */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader className="items-start">
<AlertDialogTitle>
<UIText className="text-left text-lg font-semibold">Cancel event</UIText>
</AlertDialogTitle>
<AlertDialogDescription>
<UIText className="text-left text-sm text-muted-foreground">
Cancellation reason will be shared with guests
</UIText>
</AlertDialogDescription>
</AlertDialogHeader>
<View>
<UIText className="mb-2 text-sm font-medium">Reason for cancellation</UIText>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
placeholder="Why are you cancelling?"
placeholderTextColor="#9CA3AF"
value={cancellationReason}
onChangeText={setCancellationReason}
autoFocus
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
<AlertDialogFooter>
<AlertDialogCancel onPress={handleCloseCancelDialog}>
<UIText>Nevermind</UIText>
</AlertDialogCancel>
<AlertDialogAction onPress={handleConfirmCancel}>
<UIText className="text-white">Cancel event</UIText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -12,8 +12,8 @@ import { isLiquidGlassAvailable } from "expo-glass-effect";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useUpdateLocation } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
import { safeLogError } from "@/utils/safeLogger";
// Location types configuration
@@ -96,7 +96,9 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
const [selectedType, setSelectedType] = useState<LocationTypeId>("link");
const [inputValue, setInputValue] = useState("");
const [isSaving, setIsSaving] = useState(false);
// Use React Query mutation for automatic cache invalidation
const { mutate: updateLocation, isPending: isSaving } = useUpdateLocation();
// Detect location type from current location but don't pre-fill the input
useEffect(() => {
@@ -124,7 +126,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
[selectedType]
);
const handleSubmit = useCallback(async () => {
const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
const trimmedValue = inputValue.trim();
@@ -159,19 +161,24 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
locationPayload = { type: "address", address: trimmedValue };
}
setIsSaving(true);
try {
await CalComAPIService.updateLocationV2(booking.uid, locationPayload);
Alert.alert("Success", "Location updated successfully", [
{ text: "OK", onPress: onSuccess },
]);
setIsSaving(false);
} catch (error) {
safeLogError("[EditLocationScreen] Failed to update location:", error);
Alert.alert("Error", "Failed to update location. Please try again.");
setIsSaving(false);
}
}, [booking, selectedType, inputValue, onSuccess, isSaving]);
updateLocation(
{
uid: booking.uid,
location: locationPayload,
},
{
onSuccess: () => {
Alert.alert("Success", "Location updated successfully", [
{ text: "OK", onPress: onSuccess },
]);
},
onError: (error) => {
safeLogError("[EditLocationScreen] Failed to update location:", error);
Alert.alert("Error", "Failed to update location. Please try again.");
},
}
);
}, [booking, selectedType, inputValue, onSuccess, isSaving, updateLocation]);
// Expose submit function to parent via ref
useImperativeHandle(
@@ -29,8 +29,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useUpdateLocation } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
import { safeLogError } from "@/utils/safeLogger";
export const LOCATION_TYPES = {
@@ -113,7 +113,9 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
const [selectedType, setSelectedType] = useState<LocationTypeId>("link");
const [inputValue, setInputValue] = useState("");
const [showTypePicker, setShowTypePicker] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Use React Query mutation for automatic cache invalidation
const { mutate: updateLocation, isPending: isSaving } = useUpdateLocation();
// Detect location type from current location but don't pre-fill the input
useEffect(() => {
@@ -130,7 +132,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
const selectedTypeConfig = LOCATION_TYPES[selectedType];
const handleSubmit = useCallback(async () => {
const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
const trimmedValue = inputValue.trim();
@@ -164,19 +166,24 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
locationPayload = { type: "address", address: trimmedValue };
}
setIsSaving(true);
try {
await CalComAPIService.updateLocationV2(booking.uid, locationPayload);
Alert.alert("Success", "Location updated successfully", [
{ text: "OK", onPress: onSuccess },
]);
setIsSaving(false);
} catch (error) {
safeLogError("[EditLocationScreen] Failed to update location:", error);
Alert.alert("Error", "Failed to update location. Please try again.");
setIsSaving(false);
}
}, [booking, selectedType, inputValue, onSuccess, isSaving]);
updateLocation(
{
uid: booking.uid,
location: locationPayload,
},
{
onSuccess: () => {
Alert.alert("Success", "Location updated successfully", [
{ text: "OK", onPress: onSuccess },
]);
},
onError: (error) => {
safeLogError("[EditLocationScreen] Failed to update location:", error);
Alert.alert("Error", "Failed to update location. Please try again.");
},
}
);
}, [booking, selectedType, inputValue, onSuccess, isSaving, updateLocation]);
useImperativeHandle(
ref,
@@ -9,8 +9,8 @@ import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { ActivityIndicator, Alert, FlatList, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useMarkNoShow } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
interface Attendee {
id?: number | string;
@@ -53,14 +53,16 @@ export function MarkNoShowScreen({
onBookingUpdate,
transparentBackground = false,
}: MarkNoShowScreenProps) {
"use no memo";
const insets = useSafeAreaInsets();
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]";
const safeAttendees = Array.isArray(attendees) ? attendees : [];
const [processingEmail, setProcessingEmail] = useState<string | null>(null);
const handleMarkNoShow = async (attendee: Attendee) => {
// Mark no-show mutation
const markNoShowMutation = useMarkNoShow();
const handleMarkNoShow = (attendee: Attendee) => {
if (!booking) return;
const isCurrentlyNoShow = attendee.noShow === true;
@@ -74,65 +76,72 @@ export function MarkNoShowScreen({
{
text: "Confirm",
style: isCurrentlyNoShow ? "default" : "destructive",
onPress: async () => {
onPress: () => {
setProcessingEmail(attendee.email);
try {
const updatedBooking = await CalComAPIService.markAbsent(
booking.uid,
attendee.email,
!isCurrentlyNoShow
);
markNoShowMutation.mutate(
{
uid: booking.uid,
attendeeEmail: attendee.email,
absent: !isCurrentlyNoShow,
},
{
onSuccess: (updatedBooking) => {
// API returns "absent" field, not "noShow"
const updatedAttendees: Attendee[] = [];
if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
updatedBooking.attendees.forEach(
(att: {
id?: number | string;
email: string;
name?: string;
noShow?: boolean;
absent?: boolean;
}) => {
updatedAttendees.push({
id: att.id,
email: att.email,
name: att.name || att.email,
noShow: att.absent === true || att.noShow === true,
});
}
);
}
// API returns "absent" field, not "noShow"
const updatedAttendees: Attendee[] = [];
if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
updatedBooking.attendees.forEach(
(att: {
id?: number | string;
email: string;
name?: string;
noShow?: boolean;
absent?: boolean;
}) => {
updatedAttendees.push({
id: att.id,
email: att.email,
name: att.name || att.email,
noShow: att.absent === true || att.noShow === true,
onUpdate(updatedAttendees);
if (onBookingUpdate) {
onBookingUpdate(updatedBooking);
}
Alert.alert(
"Success",
`${attendee.name || attendee.email} has been ${
isCurrentlyNoShow ? "unmarked as no-show" : "marked as no-show"
}`
);
setProcessingEmail(null);
},
onError: (error) => {
console.error("[MarkNoShowScreen] Failed to mark no-show:", error);
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[MarkNoShowScreen] Error details:", {
message,
stack,
attendeeEmail: maskEmail(attendee.email),
bookingUid: booking.uid,
absent: !isCurrentlyNoShow,
});
}
);
Alert.alert(
"Error",
error instanceof Error ? error.message : `Failed to ${action}`
);
setProcessingEmail(null);
},
}
onUpdate(updatedAttendees);
if (onBookingUpdate) {
onBookingUpdate(updatedBooking);
}
Alert.alert(
"Success",
`${attendee.name || attendee.email} has been ${
isCurrentlyNoShow ? "unmarked as no-show" : "marked as no-show"
}`
);
setProcessingEmail(null);
} catch (error) {
console.error("[MarkNoShowScreen] Failed to mark no-show:", error);
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[MarkNoShowScreen] Error details:", {
message,
stack,
attendeeEmail: maskEmail(attendee.email),
bookingUid: booking.uid,
absent: !isCurrentlyNoShow,
});
}
Alert.alert("Error", error instanceof Error ? error.message : `Failed to ${action}`);
setProcessingEmail(null);
}
);
},
},
]
@@ -171,7 +180,7 @@ export function MarkNoShowScreen({
>
<Text
className={`text-[16px] font-semibold ${
transparentBackground ? "text-white" : isNoShow ? "text-[#DC2626]" : "text-gray-600"
transparentBackground ? "text-white" : isNoShow ? "text-[#800020]" : "text-gray-600"
}`}
>
{getInitials(item.name)}
@@ -182,8 +191,8 @@ export function MarkNoShowScreen({
<Text className="mt-0.5 text-[15px] text-gray-500">{item.email}</Text>
{isNoShow && (
<View className="mt-1.5 flex-row items-center">
<Ionicons name="eye-off" size={12} color="#DC2626" />
<Text className="ml-1 text-[13px] font-medium text-[#DC2626]">Marked as no-show</Text>
<Ionicons name="eye-off" size={12} color="#800020" />
<Text className="ml-1 text-[13px] font-medium text-[#800020]">Marked as no-show</Text>
</View>
)}
</View>
@@ -201,11 +210,11 @@ export function MarkNoShowScreen({
<Ionicons
name={isNoShow ? "eye" : "eye-off"}
size={15}
color={isNoShow ? "#16A34A" : "#DC2626"}
color={isNoShow ? "#16A34A" : "#800020"}
style={{ marginRight: 5 }}
/>
<Text
className={`text-[14px] font-semibold ${isNoShow ? "text-[#16A34A]" : "text-[#DC2626]"}`}
className={`text-[14px] font-semibold ${isNoShow ? "text-[#16A34A]" : "text-[#800020]"}`}
>
{isNoShow ? "Unmark" : "Mark"}
</Text>
@@ -20,8 +20,8 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { useRescheduleBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
export interface RescheduleScreenProps {
@@ -52,7 +52,9 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false);
const [reason, setReason] = useState("");
const [isSaving, setIsSaving] = useState(false);
// Use React Query mutation for automatic cache invalidation
const { mutate: rescheduleBooking, isPending: isSaving } = useRescheduleBooking();
useEffect(() => {
if (booking?.startTime) {
@@ -68,7 +70,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
onSavingChange?.(isSaving);
}, [isSaving, onSavingChange]);
const handleSubmit = useCallback(async () => {
const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
if (selectedDateTime <= new Date()) {
@@ -76,23 +78,26 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
return;
}
setIsSaving(true);
const reschedulingReason = reason.trim() || undefined;
try {
await CalComAPIService.rescheduleBooking(booking.uid, {
rescheduleBooking(
{
uid: booking.uid,
start: selectedDateTime.toISOString(),
reschedulingReason,
});
Alert.alert("Success", "Booking rescheduled successfully", [
{ text: "OK", onPress: onSuccess },
]);
setIsSaving(false);
} catch (error) {
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
setIsSaving(false);
}
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
},
{
onSuccess: () => {
Alert.alert("Success", "Booking rescheduled successfully", [
{ text: "OK", onPress: onSuccess },
]);
},
onError: (error) => {
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
},
}
);
}, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
// Format date for display
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
@@ -10,8 +10,8 @@ import { Ionicons } from "@expo/vector-icons";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRescheduleBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
export interface RescheduleScreenProps {
@@ -31,12 +31,13 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
{ booking, onSuccess, onSavingChange, transparentBackground = false },
ref
) {
"use no memo";
const insets = useSafeAreaInsets();
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
const [reason, setReason] = useState("");
const [isSaving, setIsSaving] = useState(false);
// Use React Query mutation for automatic cache invalidation
const { mutate: rescheduleBooking, isPending: isSaving } = useRescheduleBooking();
useEffect(() => {
if (booking?.startTime) {
@@ -52,7 +53,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
onSavingChange?.(isSaving);
}, [isSaving, onSavingChange]);
const handleSubmit = useCallback(async () => {
const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
if (selectedDateTime <= new Date()) {
@@ -60,22 +61,25 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
return;
}
setIsSaving(true);
try {
await CalComAPIService.rescheduleBooking(booking.uid, {
rescheduleBooking(
{
uid: booking.uid,
start: selectedDateTime.toISOString(),
reschedulingReason: reason.trim() || undefined,
});
Alert.alert("Success", "Booking rescheduled successfully", [
{ text: "OK", onPress: onSuccess },
]);
setIsSaving(false);
} catch (error) {
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
setIsSaving(false);
}
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
},
{
onSuccess: () => {
Alert.alert("Success", "Booking rescheduled successfully", [
{ text: "OK", onPress: onSuccess },
]);
},
onError: (error) => {
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
},
}
);
}, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
const handleDateSelected = useCallback(
(date: Date) => {
@@ -20,8 +20,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRescheduleBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
const isWeb = Platform.OS === "web";
@@ -51,7 +51,9 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false);
const [reason, setReason] = useState("");
const [isSaving, setIsSaving] = useState(false);
// Use React Query mutation for automatic cache invalidation
const { mutate: rescheduleBooking, isPending: isSaving } = useRescheduleBooking();
// Pre-fill with current booking date/time
useEffect(() => {
@@ -69,7 +71,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
onSavingChange?.(isSaving);
}, [isSaving, onSavingChange]);
const handleSubmit = useCallback(async () => {
const handleSubmit = useCallback(() => {
if (!booking || isSaving) return;
// Validate the date is in the future
@@ -78,27 +80,30 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
return;
}
// Extract conditional values before try/catch for React Compiler optimization
// Extract conditional values for React Compiler optimization
const trimmedReason = reason.trim();
const reschedulingReason = trimmedReason.length > 0 ? trimmedReason : undefined;
const startTime = selectedDateTime.toISOString();
setIsSaving(true);
try {
await CalComAPIService.rescheduleBooking(booking.uid, {
rescheduleBooking(
{
uid: booking.uid,
start: startTime,
reschedulingReason,
});
Alert.alert("Success", "Booking rescheduled successfully", [
{ text: "OK", onPress: onSuccess },
]);
setIsSaving(false);
} catch (error) {
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
setIsSaving(false);
}
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
},
{
onSuccess: () => {
Alert.alert("Success", "Booking rescheduled successfully", [
{ text: "OK", onPress: onSuccess },
]);
},
onError: (error) => {
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
},
}
);
}, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
// Helper function to format date as YYYY-MM-DD in local timezone (avoids UTC conversion issues)
const formatLocalDate = (date: Date) => {
+2 -2
View File
@@ -18,7 +18,7 @@
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive: 350 100% 25%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
@@ -41,7 +41,7 @@
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive: 350 100% 15%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
+4 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import type { NativeSyntheticEvent } from "react-native";
import type { BookingFilters } from "./useBookings";
export type BookingFilter = "upcoming" | "unconfirmed" | "past" | "cancelled";
export type BookingFilter = "upcoming" | "unconfirmed" | "recurring" | "past" | "cancelled";
export interface BookingFilterOption {
key: BookingFilter;
@@ -12,6 +12,7 @@ export interface BookingFilterOption {
const FILTER_OPTIONS: BookingFilterOption[] = [
{ key: "upcoming", label: "Upcoming" },
{ key: "unconfirmed", label: "Unconfirmed" },
{ key: "recurring", label: "Recurring" },
{ key: "past", label: "Past" },
{ key: "cancelled", label: "Cancelled" },
];
@@ -71,6 +72,8 @@ export function useActiveBookingFilter(
return { status: ["upcoming"], limit: 50 };
case "unconfirmed":
return { status: ["unconfirmed"], limit: 50 };
case "recurring":
return { status: ["recurring"], limit: 100 };
case "past":
return { status: ["past"], limit: 100 };
case "cancelled":
+117
View File
@@ -116,6 +116,46 @@ export function useCancelBooking() {
});
}
/**
* Hook to mark an attendee as no-show (absent)
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: markNoShow, isPending } = useMarkNoShow();
*
* markNoShow({ uid: 'abc-123', attendeeEmail: 'attendee@example.com', absent: true });
* ```
*/
export function useMarkNoShow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
uid,
attendeeEmail,
absent,
}: {
uid: string;
attendeeEmail: string;
absent: boolean;
}) => CalComAPIService.markAbsent(uid, attendeeEmail, absent),
onSuccess: (_, variables) => {
// Invalidate all booking queries to refetch fresh data
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
// Also invalidate the specific booking detail
queryClient.invalidateQueries({
queryKey: queryKeys.bookings.detail(variables.uid),
});
},
onError: (_error) => {
console.error("Failed to mark attendee as no-show");
},
});
}
/**
* Hook to reschedule a booking
*
@@ -225,6 +265,83 @@ export function useDeclineBooking() {
});
}
/**
* Hook to update the location of a booking
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: updateLocation, isPending } = useUpdateLocation();
*
* updateLocation({
* uid: 'abc-123',
* location: { type: 'link', link: 'https://meet.example.com' }
* });
* ```
*/
export function useUpdateLocation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
uid,
location,
}: {
uid: string;
location: { type: string; [key: string]: string };
}) => CalComAPIService.updateLocationV2(uid, location),
onSuccess: (_, variables) => {
// Invalidate all booking queries
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
// Also invalidate the specific booking detail
queryClient.invalidateQueries({
queryKey: queryKeys.bookings.detail(variables.uid),
});
},
onError: (_error) => {
console.error("Failed to update location");
},
});
}
/**
* Hook to add guests to a booking
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: addGuests, isPending } = useAddGuests();
*
* addGuests({
* uid: 'abc-123',
* guests: [{ email: 'guest@example.com', name: 'Guest Name' }]
* });
* ```
*/
export function useAddGuests() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ uid, guests }: { uid: string; guests: { email: string; name?: string }[] }) =>
CalComAPIService.addGuests(uid, guests),
onSuccess: (_, variables) => {
// Invalidate all booking queries
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
// Also invalidate the specific booking detail
queryClient.invalidateQueries({
queryKey: queryKeys.bookings.detail(variables.uid),
});
},
onError: (_error) => {
console.error("Failed to add guests");
},
});
}
/**
* Hook to prefetch bookings (useful for navigation)
*
+191
View File
@@ -0,0 +1,191 @@
import { useCallback, useMemo, useState } from "react";
import type { EventType } from "@/services/calcom";
// Sort options for event types
export type EventTypeSortOption = "alphabetical" | "newest" | "duration";
// Filter options (multi-select toggles)
// When ALL filters are OFF: show ALL event types (no filtering)
// When a filter is ON: only show events matching that filter
export interface EventTypeFilters {
hiddenOnly: boolean; // When ON, show ONLY hidden events
paidOnly: boolean; // When ON, show ONLY paid events
seatedOnly: boolean; // When ON, show ONLY seated events
requiresConfirmationOnly: boolean; // When ON, show ONLY events requiring confirmation
recurringOnly: boolean; // When ON, show ONLY recurring events
}
const DEFAULT_FILTERS: EventTypeFilters = {
hiddenOnly: false,
paidOnly: false,
seatedOnly: false,
requiresConfirmationOnly: false,
recurringOnly: false,
};
interface UseEventTypeFilterResult {
// State
sortBy: EventTypeSortOption;
filters: EventTypeFilters;
// Actions
setSortBy: (sort: EventTypeSortOption) => void;
toggleFilter: (filterKey: keyof EventTypeFilters) => void;
resetFilters: () => void;
// Computed
filteredAndSortedEventTypes: (eventTypes: EventType[]) => EventType[];
activeFilterCount: number;
}
/**
* Helper function to sort event types by the given sort option.
*/
function sortEventTypes(eventTypes: EventType[], sortBy: EventTypeSortOption): EventType[] {
return [...eventTypes].sort((a, b) => {
switch (sortBy) {
case "alphabetical":
return a.title.localeCompare(b.title);
case "newest":
return b.id - a.id;
case "duration": {
const durationA = a.lengthInMinutes || a.length || 0;
const durationB = b.lengthInMinutes || b.length || 0;
return durationA - durationB;
}
default:
return 0;
}
});
}
/**
* Hook to manage event type filtering and sorting.
*
* Supports:
* - Sorting: Alphabetical, Newest First, By Duration
* - Filters (multi-select toggles):
* - Include Hidden: Show hidden event types
* - Paid Only: Show only paid events (price > 0)
* - Seated Only: Show only seated events
* - Requires Confirmation: Show only events requiring confirmation
* - Recurring Only: Show only recurring events
*/
export function useEventTypeFilter(): UseEventTypeFilterResult {
const [sortBy, setSortBy] = useState<EventTypeSortOption>("alphabetical");
const [filters, setFilters] = useState<EventTypeFilters>(DEFAULT_FILTERS);
const toggleFilter = useCallback((filterKey: keyof EventTypeFilters) => {
setFilters((prev) => ({
...prev,
[filterKey]: !prev[filterKey],
}));
}, []);
const resetFilters = useCallback(() => {
setFilters(DEFAULT_FILTERS);
}, []);
// Count active filters
const activeFilterCount = useMemo(() => {
let count = 0;
if (filters.hiddenOnly) count++;
if (filters.paidOnly) count++;
if (filters.seatedOnly) count++;
if (filters.requiresConfirmationOnly) count++;
if (filters.recurringOnly) count++;
return count;
}, [filters]);
const filteredAndSortedEventTypes = useCallback(
(eventTypes: EventType[]): EventType[] => {
if (!eventTypes || eventTypes.length === 0) {
return [];
}
// Check if ANY filter is ON
const hasAnyFilter =
filters.hiddenOnly ||
filters.paidOnly ||
filters.seatedOnly ||
filters.requiresConfirmationOnly ||
filters.recurringOnly;
// If NO filters are enabled, show ALL event types (no filtering)
if (!hasAnyFilter) {
return sortEventTypes(eventTypes, sortBy);
}
// Apply filters - each filter narrows down results (AND logic)
const filtered = eventTypes.filter((eventType) => {
// Hidden filter: when ON, show ONLY hidden events
if (filters.hiddenOnly && eventType.hidden !== true) {
return false;
}
// Paid filter: if enabled, only show paid events (price > 0)
if (filters.paidOnly) {
const isPaid =
eventType.price !== undefined && eventType.price !== null && eventType.price > 0;
if (!isPaid) {
return false;
}
}
// Seated filter: check if seats is defined and not disabled
if (filters.seatedOnly) {
const hasSeats =
eventType.seats &&
typeof eventType.seats === "object" &&
!("disabled" in eventType.seats && eventType.seats.disabled === true);
if (!hasSeats) {
return false;
}
}
// Requires confirmation filter - check both requiresConfirmation and confirmationPolicy
if (filters.requiresConfirmationOnly) {
const hasRequiresConfirmation = eventType.requiresConfirmation === true;
const hasConfirmationPolicy =
eventType.confirmationPolicy &&
typeof eventType.confirmationPolicy === "object" &&
!(
"disabled" in eventType.confirmationPolicy &&
eventType.confirmationPolicy.disabled === true
);
if (!hasRequiresConfirmation && !hasConfirmationPolicy) {
return false;
}
}
// Recurring filter: check if recurrence is defined and not disabled
if (filters.recurringOnly) {
const isRecurring =
eventType.recurrence &&
typeof eventType.recurrence === "object" &&
!("disabled" in eventType.recurrence && eventType.recurrence.disabled === true);
if (!isRecurring) {
return false;
}
}
return true;
});
// Apply sorting using helper function
return sortEventTypes(filtered, sortBy);
},
[sortBy, filters]
);
return {
sortBy,
filters,
setSortBy,
toggleFilter,
resetFilters,
filteredAndSortedEventTypes,
activeFilterCount,
};
}
+34 -13
View File
@@ -184,6 +184,8 @@ export const getBookingParticipation = (
// Module-level state (previously private static)
let _userProfile: UserProfile | null = null;
// In-flight promise to prevent concurrent /me API calls
let _userProfilePromise: Promise<UserProfile> | null = null;
/**
* Set OAuth access token for authentication
@@ -288,14 +290,32 @@ async function updateUserProfile(updates: {
}
}
// Get and cache user profile
// Get and cache user profile with in-flight deduplication
// This prevents multiple concurrent callers from each making a /me API call
async function getUserProfile(): Promise<UserProfile> {
// Return cached profile if available
if (_userProfile) {
return _userProfile;
}
_userProfile = await getCurrentUser();
return _userProfile;
// If there's already an in-flight request, wait for it instead of making a new one
if (_userProfilePromise) {
return _userProfilePromise;
}
// Create a new request and cache the promise to deduplicate concurrent calls
_userProfilePromise = getCurrentUser()
.then((profile) => {
_userProfile = profile;
_userProfilePromise = null;
return profile;
})
.catch((error) => {
_userProfilePromise = null;
throw error;
});
return _userProfilePromise;
}
// Get cached username or fetch if not available
@@ -313,6 +333,7 @@ async function buildEventTypeLink(eventTypeSlug: string): Promise<string> {
// Clear cached profile (useful for logout)
function clearUserProfile(): void {
_userProfile = null;
_userProfilePromise = null;
}
// Test function for bookings API specifically
@@ -853,13 +874,13 @@ async function getTranscripts(bookingUid: string): Promise<BookingTranscript[]>
}
async function getEventTypes(): Promise<EventType[]> {
// Get current user to extract username
// Get cached user profile to extract username (uses in-flight deduplication)
let username: string | undefined;
try {
const currentUser = await getCurrentUser();
const userProfile = await getUserProfile();
// Extract username from response
if (currentUser?.username) {
username = currentUser.username;
if (userProfile?.username) {
username = userProfile.username;
}
} catch (_error) {}
@@ -1011,10 +1032,10 @@ async function getBookings(filters?: {
}
}
// Get current user to filter bookings
let currentUser: UserProfile | undefined;
// Get cached user profile to filter bookings (uses in-flight deduplication)
let userProfile: UserProfile | undefined;
try {
currentUser = await getCurrentUser();
userProfile = await getUserProfile();
} catch (_error) {
return bookingsArray;
}
@@ -1023,9 +1044,9 @@ async function getBookings(filters?: {
let userId: number | undefined;
let userEmail: string | undefined;
if (currentUser) {
userId = currentUser.id;
userEmail = currentUser.email;
if (userProfile) {
userId = userProfile.id;
userEmail = userProfile.email;
}
// Filter bookings to only show ones where the current user is participating
@@ -47,6 +47,7 @@ export interface Booking {
fromReschedule?: string;
recurringEventId?: string;
recurringBookingUid?: string;
requiresConfirmation?: boolean;
smsReminderNumber?: string;
location?: string;
cancellationReason?: string;
@@ -48,6 +48,7 @@ export interface BookerLayouts {
}
export interface ConfirmationPolicy {
type?: "always";
noticeThreshold?: {
count: number;
unit: "hours" | "minutes";
@@ -216,6 +217,16 @@ export interface EventType {
userId: number;
isFixed: boolean;
}>;
users?: Array<{
id: number;
name?: string;
username?: string;
avatarUrl?: string;
brandColor?: string | null;
darkBrandColor?: string | null;
weekStart?: string;
metadata?: Record<string, unknown>;
}>;
// Metadata
metadata?: Record<string, unknown>;
+1 -1
View File
@@ -60,7 +60,7 @@ module.exports = {
success: "#34C759",
warning: "#FF9500",
error: "#FF3B30",
destructive: "#DC2626",
destructive: "#800020",
},
brand: {
DEFAULT: "#292929",
+133 -1
View File
@@ -29,6 +29,12 @@ export const getEmptyStateContent = (activeFilter: BookingFilter) => {
title: "No cancelled bookings",
text: "Your canceled bookings will show up here.",
};
case "recurring":
return {
icon: "repeat-outline" as const,
title: "No recurring bookings",
text: "Your recurring bookings will show up here.",
};
default:
return {
icon: "calendar-outline" as const,
@@ -154,12 +160,68 @@ export const getMonthYearKey = (dateString: string): string => {
}
};
/**
* Format recurrence pattern into human-readable text
* @param bookings Array of bookings in the recurring series
* @returns Formatted recurrence text (e.g., "Every week for 5 occurrences")
*/
export const formatRecurrencePattern = (bookings: Booking[]): string | null => {
if (bookings.length === 0) return null;
// Try to determine frequency from booking dates
if (bookings.length < 2) {
return `${bookings.length} occurrence`;
}
// Sort bookings by date
const sortedBookings = [...bookings].sort((a, b) => {
const aTime = new Date(a.start || a.startTime || "").getTime();
const bTime = new Date(b.start || b.startTime || "").getTime();
return aTime - bTime;
});
// Calculate interval between first two bookings
const first = new Date(sortedBookings[0].start || sortedBookings[0].startTime || "");
const second = new Date(sortedBookings[1].start || sortedBookings[1].startTime || "");
const diffMs = second.getTime() - first.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
let frequency = "";
if (diffDays === 1) {
frequency = "day";
} else if (diffDays === 7) {
frequency = "week";
} else if (diffDays >= 28 && diffDays <= 31) {
frequency = "month";
} else if (diffDays >= 365 && diffDays <= 366) {
frequency = "year";
} else {
// Unknown frequency, just show count
return `${bookings.length} occurrences`;
}
return `Every ${frequency} for ${bookings.length} occurrences`;
};
/**
* Represents a group of recurring bookings with the same recurringBookingUid
*/
export interface RecurringBookingGroup {
recurringBookingUid: string;
bookings: Booking[];
firstUpcoming: Booking;
remainingCount: number;
hasUnconfirmed: boolean;
recurrenceText: string | null;
}
/**
* Type definition for list items (used in FlatList)
*/
export type ListItem =
| { type: "monthHeader"; monthYear: string; key: string }
| { type: "booking"; booking: Booking; key: string };
| { type: "booking"; booking: Booking; key: string }
| { type: "recurringGroup"; group: RecurringBookingGroup; key: string };
/**
* Group bookings by month for display in a sectioned list
@@ -196,6 +258,76 @@ export const groupBookingsByMonth = (bookings: Booking[]): ListItem[] => {
return grouped;
};
/**
* Group recurring bookings by their recurringBookingUid
* @param bookings Array of recurring bookings to group
* @returns Array of RecurringBookingGroup objects
*/
export const groupRecurringBookings = (bookings: Booking[]): RecurringBookingGroup[] => {
const now = new Date();
const groupMap = new Map<string, Booking[]>();
// Group bookings by recurringBookingUid
bookings.forEach((booking) => {
const uid = booking.recurringBookingUid;
if (!uid) return;
if (!groupMap.has(uid)) {
groupMap.set(uid, []);
}
groupMap.get(uid)?.push(booking);
});
// Convert map to RecurringBookingGroup array
const groups: RecurringBookingGroup[] = [];
groupMap.forEach((groupBookings, recurringBookingUid) => {
// Sort bookings by start time (ascending)
const sortedBookings = [...groupBookings].sort((a, b) => {
const aStart = new Date(a.start || a.startTime || "").getTime();
const bStart = new Date(b.start || b.startTime || "").getTime();
return aStart - bStart;
});
// Find first upcoming (non-cancelled) booking
const upcomingBookings = sortedBookings.filter((b) => {
const startTime = new Date(b.start || b.startTime || "");
const isCancelled = b.status?.toLowerCase() === "cancelled";
const isRejected = b.status?.toLowerCase() === "rejected";
return startTime >= now && !isCancelled && !isRejected;
});
const firstUpcoming = upcomingBookings[0] || sortedBookings[0];
// Count remaining (non-cancelled, non-rejected) bookings - reuse upcomingBookings
const remainingCount = upcomingBookings.length;
// Check if any booking requires confirmation
const hasUnconfirmed = sortedBookings.some(
(b) =>
b.status?.toLowerCase() === "pending" ||
b.status?.toLowerCase() === "requires_confirmation" ||
b.requiresConfirmation
);
groups.push({
recurringBookingUid,
bookings: sortedBookings,
firstUpcoming,
remainingCount,
hasUnconfirmed,
recurrenceText: formatRecurrencePattern(sortedBookings),
});
});
// Sort groups by first upcoming booking date
return groups.sort((a, b) => {
const aStart = new Date(a.firstUpcoming.start || a.firstUpcoming.startTime || "").getTime();
const bStart = new Date(b.firstUpcoming.start || b.firstUpcoming.startTime || "").getTime();
return aStart - bStart;
});
};
/**
* Search/filter bookings by query string
* @param bookings Array of bookings to filter