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:
@@ -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 */}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
export default function TabsIndex() {
|
||||
return <Redirect href="/(tabs)/(event-types)" />;
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -60,7 +60,7 @@ module.exports = {
|
||||
success: "#34C759",
|
||||
warning: "#FF9500",
|
||||
error: "#FF3B30",
|
||||
destructive: "#DC2626",
|
||||
destructive: "#800020",
|
||||
},
|
||||
brand: {
|
||||
DEFAULT: "#292929",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user