* react compiler * remove compilation mode 'all', it will use infer by default * feat(companion): add theme tokens, path aliases, and component improvements - Extend tailwind.config.js with Cal.com brand color tokens (cal.text, cal.bg, cal.border, cal.accent, cal.brand) - Create theme/colors.ts for JS usage where Tailwind classes can't be used - Configure path aliases in tsconfig.json (@/components, @/hooks, @/utils, etc.) - Add expo-image and expo-haptics dependencies - Enhance AppPressable with Reanimated animations (opacity + scale) and haptic feedback - Extract shared logic from BookingListItem into useBookingListItemData hook and BookingListItemParts - Extract shared logic from EventTypeListItem into useEventTypeListItemData hook and EventTypeListItemParts - Extract shared logic from AvailabilityListItem into AvailabilityListItemParts - Update all list item components to use new theme tokens instead of hardcoded hex values * fix react compiler non memo issue * feat(companion): migrate to path aliases and expo-image - Remove theme/colors.ts and theme/index.ts (defer dark mode to future PR) - Replace colors import with hardcoded hex values in components - Migrate all files to use path aliases (@/components/*, @/hooks/*, etc.) - Migrate SvgImage to use expo-image for better performance - Update tsconfig.json to remove @/theme/* alias * fix(companion): add path aliases to extension tsconfig Add baseUrl and paths configuration to tsconfig.extension.json to support path aliases in shared types used by the extension build.
332 lines
9.9 KiB
TypeScript
332 lines
9.9 KiB
TypeScript
/**
|
|
* BookingActionsModal Component - Android/Web Implementation
|
|
*
|
|
* A reusable modal component for booking actions that can be used in both
|
|
* the bookings list screen and the booking detail screen.
|
|
*
|
|
* This component uses the centralized action gating utility for consistent
|
|
* action visibility and enabled state across the app.
|
|
*
|
|
* Note: iOS uses BookingActionsModal.ios.tsx with native Glass UI styling.
|
|
*/
|
|
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
|
|
import type { Booking } from "@/services/calcom";
|
|
import type { BookingActionsResult } from "@/utils/booking-actions";
|
|
import { FullScreenModal } from "./FullScreenModal";
|
|
|
|
export interface BookingActionsModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
booking: Booking | null;
|
|
actions: BookingActionsResult;
|
|
onReschedule: () => void;
|
|
onRequestReschedule?: () => void;
|
|
onEditLocation: () => void;
|
|
onAddGuests: () => void;
|
|
onViewRecordings: () => void;
|
|
onMeetingSessionDetails: () => void;
|
|
onMarkNoShow: () => void;
|
|
onReportBooking: () => void;
|
|
onCancelBooking: () => void;
|
|
}
|
|
|
|
// Icon mapping for actions
|
|
const ACTION_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
|
|
reschedule: "calendar-outline",
|
|
rescheduleRequest: "send-outline",
|
|
changeLocation: "location-outline",
|
|
addGuests: "person-add-outline",
|
|
viewRecordings: "videocam-outline",
|
|
meetingSessionDetails: "information-circle-outline",
|
|
markNoShow: "eye-off-outline",
|
|
report: "flag-outline",
|
|
cancel: "close-circle-outline",
|
|
};
|
|
|
|
interface ActionButtonProps {
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
label: string;
|
|
onPress: () => void;
|
|
visible: boolean;
|
|
enabled: boolean;
|
|
isDanger?: boolean;
|
|
isLast?: boolean;
|
|
}
|
|
|
|
function ActionButton({
|
|
icon,
|
|
label,
|
|
onPress,
|
|
visible,
|
|
enabled,
|
|
isDanger = false,
|
|
isLast = false,
|
|
}: ActionButtonProps) {
|
|
if (!visible) return null;
|
|
|
|
const iconColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#6B7280";
|
|
const textColor = !enabled ? "#D1D5DB" : isDanger ? "#DC2626" : "#111827";
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
if (!enabled) return;
|
|
onPress();
|
|
}}
|
|
disabled={!enabled}
|
|
className={`flex-row items-center px-4 py-3 active:bg-gray-50 ${
|
|
!isLast ? "border-b border-gray-100" : ""
|
|
}`}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View className="mr-3 h-6 w-6 items-center justify-center">
|
|
<Ionicons name={icon} size={20} color={iconColor} />
|
|
</View>
|
|
<Text className="flex-1 text-[16px]" style={{ color: textColor }}>
|
|
{label}
|
|
</Text>
|
|
{!enabled && (
|
|
<View className="rounded bg-gray-100 px-2 py-0.5">
|
|
<Text className="text-xs text-gray-500">Unavailable</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
interface SectionHeaderProps {
|
|
title: string;
|
|
}
|
|
|
|
function SectionHeader({ title }: SectionHeaderProps) {
|
|
return (
|
|
<View className="bg-gray-50 px-4 py-2">
|
|
<Text className="text-[12px] font-semibold uppercase tracking-wide text-gray-500">
|
|
{title}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function BookingActionsModal({
|
|
visible,
|
|
onClose,
|
|
booking,
|
|
actions,
|
|
onReschedule,
|
|
onRequestReschedule,
|
|
onEditLocation,
|
|
onAddGuests,
|
|
onViewRecordings,
|
|
onMeetingSessionDetails,
|
|
onMarkNoShow,
|
|
onReportBooking,
|
|
onCancelBooking,
|
|
}: BookingActionsModalProps) {
|
|
if (!booking) return null;
|
|
|
|
const handleAction = (action: () => void) => {
|
|
onClose();
|
|
action();
|
|
};
|
|
|
|
// Check if any edit event actions are visible
|
|
const hasEditEventActions =
|
|
actions.reschedule.visible ||
|
|
actions.rescheduleRequest.visible ||
|
|
actions.changeLocation.visible ||
|
|
actions.addGuests.visible;
|
|
|
|
// Check if any after event actions are visible
|
|
const hasAfterEventActions =
|
|
actions.viewRecordings.visible ||
|
|
actions.meetingSessionDetails.visible ||
|
|
actions.markNoShow.visible;
|
|
|
|
// Define edit event actions
|
|
const editEventActions = [
|
|
{
|
|
key: "reschedule",
|
|
icon: ACTION_ICONS.reschedule,
|
|
label: "Reschedule Booking",
|
|
onPress: () => handleAction(onReschedule),
|
|
visible: actions.reschedule.visible,
|
|
enabled: actions.reschedule.enabled,
|
|
},
|
|
...(onRequestReschedule
|
|
? [
|
|
{
|
|
key: "rescheduleRequest",
|
|
icon: ACTION_ICONS.rescheduleRequest,
|
|
label: "Request Reschedule",
|
|
onPress: () => handleAction(onRequestReschedule),
|
|
visible: actions.rescheduleRequest.visible,
|
|
enabled: actions.rescheduleRequest.enabled,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
key: "changeLocation",
|
|
icon: ACTION_ICONS.changeLocation,
|
|
label: "Edit Location",
|
|
onPress: () => handleAction(onEditLocation),
|
|
visible: actions.changeLocation.visible,
|
|
enabled: actions.changeLocation.enabled,
|
|
},
|
|
{
|
|
key: "addGuests",
|
|
icon: ACTION_ICONS.addGuests,
|
|
label: "Add Guests",
|
|
onPress: () => handleAction(onAddGuests),
|
|
visible: actions.addGuests.visible,
|
|
enabled: actions.addGuests.enabled,
|
|
},
|
|
].filter((action) => action.visible);
|
|
|
|
// Define after event actions
|
|
const afterEventActions = [
|
|
{
|
|
key: "viewRecordings",
|
|
icon: ACTION_ICONS.viewRecordings,
|
|
label: "View Recordings",
|
|
onPress: () => handleAction(onViewRecordings),
|
|
visible: actions.viewRecordings.visible,
|
|
enabled: actions.viewRecordings.enabled,
|
|
},
|
|
{
|
|
key: "meetingSessionDetails",
|
|
icon: ACTION_ICONS.meetingSessionDetails,
|
|
label: "Meeting Session Details",
|
|
onPress: () => handleAction(onMeetingSessionDetails),
|
|
visible: actions.meetingSessionDetails.visible,
|
|
enabled: actions.meetingSessionDetails.enabled,
|
|
},
|
|
{
|
|
key: "markNoShow",
|
|
icon: ACTION_ICONS.markNoShow,
|
|
label: "Mark as No-Show",
|
|
onPress: () => handleAction(onMarkNoShow),
|
|
visible: actions.markNoShow.visible,
|
|
enabled: actions.markNoShow.enabled,
|
|
},
|
|
].filter((action) => action.visible);
|
|
|
|
// Define danger zone actions
|
|
const dangerZoneActions = [
|
|
{
|
|
key: "report",
|
|
icon: ACTION_ICONS.report,
|
|
label: "Report Booking",
|
|
onPress: () => handleAction(onReportBooking),
|
|
visible: true,
|
|
enabled: true,
|
|
isDanger: true,
|
|
},
|
|
{
|
|
key: "cancel",
|
|
icon: ACTION_ICONS.cancel,
|
|
label: "Cancel Event",
|
|
onPress: () => handleAction(onCancelBooking),
|
|
visible: actions.cancel.visible,
|
|
enabled: actions.cancel.enabled,
|
|
isDanger: true,
|
|
},
|
|
].filter((action) => action.visible);
|
|
|
|
return (
|
|
<FullScreenModal visible={visible} animationType="fade" onRequestClose={onClose}>
|
|
<TouchableOpacity
|
|
className="flex-1 items-center justify-center bg-black/50 p-4"
|
|
activeOpacity={1}
|
|
onPress={onClose}
|
|
>
|
|
<TouchableOpacity
|
|
className="w-full max-w-md"
|
|
activeOpacity={1}
|
|
onPress={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Actions Card */}
|
|
<View className="mb-4 overflow-hidden rounded-2xl bg-white shadow-lg">
|
|
{/* Booking Title Header */}
|
|
<View className="border-b border-gray-100 px-4 py-3">
|
|
<Text className="text-center text-[14px] font-medium text-gray-600" numberOfLines={1}>
|
|
{booking.title}
|
|
</Text>
|
|
</View>
|
|
|
|
<ScrollView className="max-h-[400px]">
|
|
{/* Edit Event Section */}
|
|
{hasEditEventActions && (
|
|
<>
|
|
<SectionHeader title="Edit Event" />
|
|
{editEventActions.map((action, index) => (
|
|
<ActionButton
|
|
key={action.key}
|
|
icon={action.icon}
|
|
label={action.label}
|
|
onPress={action.onPress}
|
|
visible={action.visible}
|
|
enabled={action.enabled}
|
|
isLast={index === editEventActions.length - 1}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* After Event Section */}
|
|
{hasAfterEventActions && (
|
|
<>
|
|
<SectionHeader title="After Event" />
|
|
{afterEventActions.map((action, index) => (
|
|
<ActionButton
|
|
key={action.key}
|
|
icon={action.icon}
|
|
label={action.label}
|
|
onPress={action.onPress}
|
|
visible={action.visible}
|
|
enabled={action.enabled}
|
|
isLast={index === afterEventActions.length - 1}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* Danger Zone Section */}
|
|
{dangerZoneActions.length > 0 && (
|
|
<>
|
|
<SectionHeader title="Danger Zone" />
|
|
{dangerZoneActions.map((action, index) => (
|
|
<ActionButton
|
|
key={action.key}
|
|
icon={action.icon}
|
|
label={action.label}
|
|
onPress={action.onPress}
|
|
visible={action.visible}
|
|
enabled={action.enabled}
|
|
isDanger={action.isDanger}
|
|
isLast={index === dangerZoneActions.length - 1}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
|
|
{/* Cancel Button */}
|
|
<TouchableOpacity
|
|
className="overflow-hidden rounded-2xl bg-white shadow-lg"
|
|
onPress={onClose}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View className="px-4 py-3">
|
|
<Text className="text-center text-[16px] font-semibold text-gray-700">Cancel</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
</TouchableOpacity>
|
|
</TouchableOpacity>
|
|
</FullScreenModal>
|
|
);
|
|
}
|