feat(companion): Add DropdownMenu and Alert Dialog for Android (#26385)

* feat(companion): add DropdownMenu for Android event types list

Replace Alert.alert with react-native-reusables DropdownMenu component
for Android event type list items, providing a native-feeling menu
experience that matches iOS functionality.

Changes:
- Install react-native-reusables dropdown-menu component and dependencies
  (lucide-react-native, tailwindcss-animate, class-variance-authority,
  clsx, tailwind-merge)
- Create EventTypeListItem.android.tsx with DropdownMenu implementation
  - Single ellipsis button triggers dropdown menu
  - Menu includes: Preview, Copy link, Edit, Duplicate, Delete
  - Delete action marked as destructive variant
  - Proper safe area insets handling
- Add PortalHost to app/_layout.tsx for portal rendering support
- Create lib/utils.ts with cn() helper for className merging
- Update global.css with theme CSS variables (popover, border, accent,
  destructive, etc.) for dropdown menu styling
- Update tailwind.config.js with theme colors and tailwindcss-animate plugin
- Update metro.config.js with inlineRem: 16 for proper rem unit handling
- Remove Android Alert.alert fallback from event types index.tsx
- Fix lint issues in generated dropdown-menu.tsx (remove unnecessary fragments)

The Android experience now matches iOS with a single menu button that
opens a dropdown containing all event type actions, replacing the
previous Alert.alert dialog and separate action buttons.

Refs: https://reactnativereusables.com/docs/components/dropdown-menu

* adjust with of menu

* feat(companion): add DropdownMenu for Android booking and availability list items

- Add BookingListItem.android.tsx with DropdownMenu for booking actions
- Add BookingDetailScreen.android.tsx with DropdownMenu in header
- Add AvailabilityListItem.android.tsx with DropdownMenu for schedule actions

Actions include: Reschedule, Edit Location, Add Guests, View Recordings,
Meeting Session Details, Mark as No-Show, Report Booking, Cancel Event
for bookings; Set as Default, Duplicate, Delete for availability schedules.

* fix lint issues

* feat(android): replace native alerts with AlertDialog and Toast in event types

- Install AlertDialog and Alert components from react-native-reusables
- Create Android-specific event types screen (index.android.tsx)
- Replace native Alert.alert() with AlertDialog for delete confirmation
- Add inline validation errors (red border + error text) in create modal
- Implement Toast snackbar for success/error notifications (no layout shift)
- Auto-dismiss toast after 2.5 seconds
- Fix React Compiler compatibility by removing animated refs

This provides a more consistent and polished UI experience on Android,
matching the design system used in other parts of the app.

* feat(android): add AlertDialog for availability delete and booking cancel

- Add index.android.tsx for availability with AlertDialog for delete confirmation
- Add index.android.tsx for bookings with AlertDialog for cancel (with reason input)
- Both screens include Toast snackbar for success/error notifications
- Follows the same pattern as event types Android implementation

* feat(companion): add DropdownMenu and AlertDialog for Android

- Replace native Alert.alert context menus with DropdownMenu component
  for event types, bookings, and availability lists
- Replace FullScreenModal with AlertDialog for create/delete/cancel flows
- Add toast notifications for success/error feedback on Android
- Set consistent 380px max-width for all AlertDialogs
- Fix layout headers showing "index" text on event-types and availability
- Create Android-specific More screen with AlertDialog logout confirmation

Uses react-native-reusables components for polished Android UI

* better code

* fix cubics comments

* refactor(android): revert delete confirmations to native Alert.alert()

- Logout: reverted to native Alert.alert() (simple yes/no)
- Event Types Delete: reverted to native Alert.alert() (simple yes/no)
- Availability Delete: reverted to native Alert.alert() (simple yes/no)

Keep AlertDialog only where user input is needed:
- Event Types Create (has TextInput for title)
- Availability Create (has TextInput for name)
- Bookings Cancel (has TextInput for cancellation reason)

* refactor(android): remove redundant index.android.tsx files

The main index.tsx files already handle Android via Platform.OS checks.
Android-specific behavior for actions is in the list item components:
- EventTypeListItem.android.tsx
- BookingListItem.android.tsx
- AvailabilityListItem.android.tsx

This consolidates the code and reduces duplication.

* corrected the implementation both native and alert dialog

* address cubics comments

* fix lint issue and address cubic comments
This commit is contained in:
Dhairyashil Shinde
2026-01-02 19:05:26 -03:00
committed by GitHub
parent c58e0ca783
commit 60a4d3f2b4
35 changed files with 3155 additions and 158 deletions
@@ -1,9 +1,9 @@
import { Stack } from "expo-router";
export default function EventTypesLayout() {
export default function AvailabilityLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{}} />
<Stack.Screen name="index" options={{ headerShown: false }} />
</Stack>
);
}
@@ -1,4 +1,5 @@
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Alert, Platform, Pressable } from "react-native";
@@ -7,7 +8,6 @@ import { useCreateSchedule, useUserProfile } from "@/hooks";
import { CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { Image } from "expo-image";
export default function Availability() {
const router = useRouter();
@@ -3,7 +3,7 @@ import { Stack } from "expo-router";
export default function EventTypesLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{}} />
<Stack.Screen name="index" options={{ headerShown: false }} />
</Stack>
);
}
@@ -3,6 +3,7 @@ import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import {
@@ -29,12 +30,11 @@ import {
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";
import { Image } from "expo-image";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
export default function EventTypesIOS() {
const router = useRouter();
+196 -73
View File
@@ -20,6 +20,17 @@ import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeLi
import { FullScreenModal } from "@/components/FullScreenModal";
import { Header } from "@/components/Header";
import { LoadingSpinner } from "@/components/LoadingSpinner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Text as AlertDialogText } from "@/components/ui/text";
import {
useCreateEventType,
useDeleteEventType,
@@ -42,6 +53,7 @@ export default function EventTypes() {
// Modal state for creating new event type
const [showCreateModal, setShowCreateModal] = useState(false);
const [newEventTitle, setNewEventTitle] = useState("");
const [titleError, setTitleError] = useState("");
// Use React Query hooks
const {
@@ -130,14 +142,8 @@ export default function EventTypes() {
return;
}
if (Platform.OS !== "ios") {
// Fallback for non-iOS platforms (Android)
Alert.alert(eventType.title, eventType.description || "", [
{ text: "Cancel", style: "cancel" },
{ text: "Edit", onPress: () => handleEdit(eventType) },
{ text: "Duplicate", onPress: () => handleDuplicate(eventType) },
{ text: "Delete", style: "destructive", onPress: () => handleDelete(eventType) },
]);
// Android handles long-press via DropdownMenu in EventTypeListItem.android.tsx
if (Platform.OS === "android") {
return;
}
@@ -216,6 +222,42 @@ export default function EventTypes() {
};
const handleDelete = (eventType: EventType) => {
// Use native Alert.alert on Android for simple yes/no confirmation
if (Platform.OS === "android") {
Alert.alert(
"Delete Event Type",
`This will permanently delete the "${eventType.title}" event type. This action cannot be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteEventTypeMutation(eventType.id, {
onSuccess: () => {
Alert.alert("Success", "Event type deleted successfully");
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error);
console.error("Failed to delete event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[EventTypes] deleteEventType failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to delete event type. Please try again.");
},
});
},
},
]
);
return;
}
// Use custom modal for iOS and web
setEventTypeToDelete(eventType);
setShowDeleteModal(true);
};
@@ -240,7 +282,10 @@ export default function EventTypes() {
console.error("Failed to delete event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[EventTypes] deleteEventType failed", { message, stack });
console.debug("[EventTypes] deleteEventType failed", {
message,
stack,
});
}
if (Platform.OS === "web") {
showToastMessage("Failed to delete event type");
@@ -285,7 +330,10 @@ export default function EventTypes() {
console.error("Failed to duplicate event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[EventTypes] duplicateEventType failed", { message, stack });
console.debug("[EventTypes] duplicateEventType failed", {
message,
stack,
});
}
if (Platform.OS === "web") {
showToastMessage("Failed to duplicate event type");
@@ -328,17 +376,30 @@ export default function EventTypes() {
const handleCloseCreateModal = () => {
setShowCreateModal(false);
setNewEventTitle("");
setTitleError("");
};
const handleCreateEventType = () => {
// Clear previous error
setTitleError("");
if (!newEventTitle.trim()) {
Alert.alert("Error", "Please enter a title for your event type");
// Use inline error for Android AlertDialog, Alert for others
if (Platform.OS === "android") {
setTitleError("Please enter a title for your event type");
} else {
Alert.alert("Error", "Please enter a title for your event type");
}
return;
}
const autoSlug = slugify(newEventTitle.trim());
if (!autoSlug) {
Alert.alert("Error", "Title must contain at least one letter or number");
if (Platform.OS === "android") {
setTitleError("Title must contain at least one letter or number");
} else {
Alert.alert("Error", "Title must contain at least one letter or number");
}
return;
}
@@ -371,7 +432,10 @@ export default function EventTypes() {
console.error("Failed to create event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[EventTypes] createEventType failed", { message, stack });
console.debug("[EventTypes] createEventType failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to create event type. Please try again.");
},
@@ -537,74 +601,133 @@ export default function EventTypes() {
</View>
</ScrollView>
{/* Create Event Type Modal */}
<FullScreenModal
visible={showCreateModal}
animationType="fade"
onRequestClose={handleCloseCreateModal}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={handleCloseCreateModal}
{/* Create Event Type Modal - Android uses AlertDialog */}
{Platform.OS === "android" ? (
<AlertDialog
open={showCreateModal}
onOpenChange={(open) => {
if (!open) handleCloseCreateModal();
}}
>
<AlertDialogContent>
<AlertDialogHeader className="items-start">
<AlertDialogTitle>
<AlertDialogText className="text-left text-lg font-semibold">
Add a new event type
</AlertDialogText>
</AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogText className="text-left text-sm text-muted-foreground">
Set up event types to offer different types of meetings.
</AlertDialogText>
</AlertDialogDescription>
</AlertDialogHeader>
{/* Title Input */}
<View>
<AlertDialogText className="mb-2 text-sm font-medium">Title</AlertDialogText>
<TextInput
className={`rounded-md border bg-white px-3 py-2.5 text-base text-[#111827] ${
titleError ? "border-red-500" : "border-[#D1D5DB]"
}`}
placeholder="Quick Chat"
placeholderTextColor="#9CA3AF"
value={newEventTitle}
onChangeText={(text) => {
setNewEventTitle(text);
if (titleError) setTitleError("");
}}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateEventType}
/>
{titleError ? (
<AlertDialogText className="mt-1 text-sm text-red-500">
{titleError}
</AlertDialogText>
) : null}
</View>
<AlertDialogFooter>
<AlertDialogCancel onPress={handleCloseCreateModal} disabled={creating}>
<AlertDialogText>Cancel</AlertDialogText>
</AlertDialogCancel>
<AlertDialogAction onPress={handleCreateEventType} disabled={creating}>
<AlertDialogText className="text-white">Continue</AlertDialogText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<FullScreenModal
visible={showCreateModal}
animationType="fade"
onRequestClose={handleCloseCreateModal}
>
<TouchableOpacity
className="max-h-[90%] w-[90%] max-w-[500px] rounded-2xl bg-white"
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={shadows.xl()}
onPress={handleCloseCreateModal}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
Add a new event type
</Text>
<Text className="text-sm text-[#6B7280]">
Set up event types to offer different types of meetings.
</Text>
</View>
{/* Content */}
<View className="px-8 pb-6">
{/* Title */}
<View className="mb-4">
<Text className="mb-2 text-sm font-medium text-[#374151]">Title</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827] focus:border-black focus:ring-2 focus:ring-black"
placeholder="Quick Chat"
placeholderTextColor="#9CA3AF"
value={newEventTitle}
onChangeText={setNewEventTitle}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateEventType}
/>
<TouchableOpacity
className="max-h-[90%] w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={shadows.xl()}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
Add a new event type
</Text>
<Text className="text-sm text-[#6B7280]">
Set up event types to offer different types of meetings.
</Text>
</View>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<TouchableOpacity
className="rounded-xl border border-[#D1D5DB] bg-white px-4 py-2"
onPress={handleCloseCreateModal}
disabled={creating}
>
<Text className="text-base font-medium text-[#374151]">Close</Text>
</TouchableOpacity>
<TouchableOpacity
className={`rounded-xl bg-[#111827] px-4 py-2 ${creating ? "opacity-60" : ""}`}
onPress={handleCreateEventType}
disabled={creating}
>
<Text className="text-base font-medium text-white">Continue</Text>
</TouchableOpacity>
{/* Content */}
<View className="px-8 pb-6">
{/* Title */}
<View className="mb-4">
<Text className="mb-2 text-sm font-medium text-[#374151]">Title</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827] focus:border-black focus:ring-2 focus:ring-black"
placeholder="Quick Chat"
placeholderTextColor="#9CA3AF"
value={newEventTitle}
onChangeText={setNewEventTitle}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateEventType}
/>
</View>
</View>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<TouchableOpacity
className="rounded-xl border border-[#D1D5DB] bg-white px-4 py-2"
onPress={handleCloseCreateModal}
disabled={creating}
>
<Text className="text-base font-medium text-[#374151]">Close</Text>
</TouchableOpacity>
<TouchableOpacity
className={`rounded-xl bg-[#111827] px-4 py-2 ${creating ? "opacity-60" : ""}`}
onPress={handleCreateEventType}
disabled={creating}
>
<Text className="text-base font-medium text-white">Continue</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
</FullScreenModal>
)}
{/* Action Modal for Web Platform */}
<FullScreenModal
+2 -2
View File
@@ -1,15 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Alert, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/hooks";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { Image } from "expo-image";
import { useUserProfile } from "@/hooks";
interface MoreMenuItem {
name: string;
+2
View File
@@ -1,3 +1,4 @@
import { PortalHost } from "@rn-primitives/portal";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack } from "expo-router";
import { Platform, StatusBar, View } from "react-native";
@@ -225,6 +226,7 @@ function RootLayoutContent() {
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
{content}
<NetworkStatusBanner />
<PortalHost />
</View>
);
}
+62
View File
@@ -9,9 +9,14 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
"@rn-primitives/alert-dialog": "^1.2.0",
"@rn-primitives/dropdown-menu": "^1.2.0",
"@rn-primitives/slot": "^1.2.0",
"@tanstack/react-query": "5.62.0",
"@tanstack/react-query-persist-client": "5.62.0",
"base64-js": "1.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "55.0.0-canary-20251230-fc48ddc",
"expo-auth-session": "7.0.11-canary-20251230-fc48ddc",
"expo-clipboard": "9.0.0-canary-20251230-fc48ddc",
@@ -28,6 +33,7 @@
"expo-splash-screen": "31.0.14-canary-20251230-fc48ddc",
"expo-status-bar": "3.0.10-canary-20251230-fc48ddc",
"expo-web-browser": "15.0.11-canary-20251230-fc48ddc",
"lucide-react-native": "^0.562.0",
"nativewind": "4.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -39,6 +45,8 @@
"react-native-svg": "15.12.1",
"react-native-web": "0.21.2",
"react-native-worklets": "0.7.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
},
"devDependencies": {
"@biomejs/biome": "2.3.10",
@@ -415,6 +423,14 @@
"@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -473,6 +489,10 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -485,12 +505,18 @@
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
@@ -513,6 +539,12 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
@@ -555,6 +587,20 @@
"@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="],
"@rn-primitives/alert-dialog": ["@rn-primitives/alert-dialog@1.2.0", "", { "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.14", "@rn-primitives/hooks": "1.3.0", "@rn-primitives/slot": "1.2.0", "@rn-primitives/types": "1.2.0" }, "peerDependencies": { "@rn-primitives/portal": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-/XxvQVRMnIyQo29iuQ631CHjghGKY8U4k3gbsS2MCGP0hglZVFoBRiAF3Bgyr16LxWRu1DoPltvzwOrUCsB+YQ=="],
"@rn-primitives/dropdown-menu": ["@rn-primitives/dropdown-menu@1.2.0", "", { "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.15", "@rn-primitives/hooks": "1.3.0", "@rn-primitives/slot": "1.2.0", "@rn-primitives/types": "1.2.0", "@rn-primitives/utils": "1.2.0" }, "peerDependencies": { "@rn-primitives/portal": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-TJDDr8VQfw9CRZ7xZ6kBYLVMqL1xFVC5ZZ4sfRmWP6PCT0lNks4XqGuTFLeVVlNLPSmzt9GKC2DZqzDXui8/NQ=="],
"@rn-primitives/hooks": ["@rn-primitives/hooks@1.3.0", "", { "dependencies": { "@rn-primitives/types": "1.2.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-BR97reSu7uVDpyMeQdRJHT0w8KdS6jdYnOL6xQtqS2q3H6N7vXBlX4LFERqJZphD+aziJFIAJ3HJF1vtt6XlpQ=="],
"@rn-primitives/portal": ["@rn-primitives/portal@1.3.0", "", { "dependencies": { "zustand": "^5.0.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-a2DSce7TcSfcs0cCngLadAJOvx/+mdH9NRu+GxkX8NPRsGGhJvDEOqouMgDqLwx7z9mjXoUaZcwaVcemUSW9/A=="],
"@rn-primitives/slot": ["@rn-primitives/slot@1.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-cpbn+JLjSeq3wcA4uqgFsUimMrWYWx2Ks7r5rkwd1ds1utxynsGkLOKpYVQkATwWrYhtcoF1raxIKEqXuMN+/w=="],
"@rn-primitives/types": ["@rn-primitives/types@1.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-b+6zKgdKVqAfaFPSfhwlQL0dnPQXPpW890m3eguC0VDI1eOsoEvUfVb6lmgH4bum9MmI0xymq4tOUI/fsKLoCQ=="],
"@rn-primitives/utils": ["@rn-primitives/utils@1.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-vLXV5NuxIHDeb4Bw57FzdUh89/g8gz6GERm8TsbJaSUPsDXfnC/ffeYiZJb0LxNteKE3Nr8na4Jy2n26tFil7w=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="],
@@ -835,6 +881,8 @@
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
@@ -849,6 +897,8 @@
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -1413,6 +1463,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react-native": ["lucide-react-native@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-native": "*", "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, "sha512-ZF2ok8SzyUaiCIrLGqYh/6SPs+huVzbZOCv0i411L4+oP3tJgQvvKePiVgWCioa7HsT2xaJZSrdd92cuB2/+ew=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
@@ -1879,8 +1931,12 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
"temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],
@@ -2067,6 +2123,8 @@
"zod-validation-error": ["zod-validation-error@3.5.4", "", { "peerDependencies": { "zod": "^3.24.4" } }, "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw=="],
"zustand": ["zustand@5.0.9", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="],
"@aklinker1/rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -2163,10 +2221,14 @@
"@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.29.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g=="],
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+121
View File
@@ -0,0 +1,121 @@
import { Ionicons } from "@expo/vector-icons";
import type { ReactNode } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { EmptyScreen } from "./EmptyScreen";
import { Header } from "./Header";
import { LoadingSpinner } from "./LoadingSpinner";
type IoniconName = keyof typeof Ionicons.glyphMap;
interface EmptyStateConfig {
icon: IoniconName;
headline: string;
description: string;
buttonText?: string;
onButtonPress?: () => void;
}
interface ScreenWrapperProps {
/** Whether data is currently loading */
loading?: boolean;
/** Error message to display, if any */
error?: string | null;
/** Callback for retry button in error state */
onRetry?: () => void;
/** Custom title for error state (default: "Unable to load data") */
errorTitle?: string;
/** Whether to show empty state */
isEmpty?: boolean;
/** Configuration for empty state display */
emptyProps?: EmptyStateConfig;
/** Whether to show the header (default: true) */
showHeader?: boolean;
/** Content to render when not in loading/error/empty state */
children: ReactNode;
}
/**
* Wrapper component that handles common screen states: loading, error, and empty.
* Renders the appropriate UI based on the current state.
*
* @example
* ```tsx
* <ScreenWrapper
* loading={isLoading}
* error={error}
* onRetry={refetch}
* errorTitle="Unable to load event types"
* isEmpty={eventTypes.length === 0}
* emptyProps={{
* icon: "calendar-outline",
* headline: "No event types",
* description: "Create your first event type to get started.",
* buttonText: "New",
* onButtonPress: handleCreate,
* }}
* >
* <EventTypesList data={eventTypes} />
* </ScreenWrapper>
* ```
*/
export function ScreenWrapper({
loading,
error,
onRetry,
errorTitle = "Unable to load data",
isEmpty,
emptyProps,
showHeader = true,
children,
}: ScreenWrapperProps) {
if (loading) {
return (
<View className="flex-1 bg-gray-100">
{showHeader && <Header />}
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<LoadingSpinner size="large" />
</View>
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-gray-100">
{showHeader && <Header />}
<View className="flex-1 items-center justify-center bg-gray-50 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">
{errorTitle}
</Text>
<Text className="mb-6 text-center text-base text-gray-500">{error}</Text>
{onRetry && (
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={onRetry}>
<Text className="text-base font-semibold text-white">Retry</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
if (isEmpty && emptyProps) {
return (
<View className="flex-1 bg-gray-100">
{showHeader && <Header />}
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<EmptyScreen {...emptyProps} />
</View>
</View>
);
}
return (
<View className="flex-1 bg-gray-100">
{showHeader && <Header />}
{children}
</View>
);
}
export type { EmptyStateConfig, ScreenWrapperProps };
+60
View File
@@ -0,0 +1,60 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TextInput, TouchableOpacity, View } from "react-native";
interface SearchHeaderProps {
/** Current search query value */
searchQuery: string;
/** Callback when search query changes */
onSearchChange: (query: string) => void;
/** Placeholder text for search input (default: "Search") */
placeholder?: string;
/** Callback when New button is pressed */
onNewPress: () => void;
/** Text for the New button (default: "New") */
newButtonText?: string;
}
/**
* Reusable search header component with a search input and "New" button.
* Used consistently across Android list screens.
*
* @example
* ```tsx
* <SearchHeader
* searchQuery={searchQuery}
* onSearchChange={setSearchQuery}
* placeholder="Search event types"
* onNewPress={handleCreateNew}
* />
* ```
*/
export function SearchHeader({
searchQuery,
onSearchChange,
placeholder = "Search",
onNewPress,
newButtonText = "New",
}: SearchHeaderProps) {
return (
<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"
placeholder={placeholder}
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={onSearchChange}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={onNewPress}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">{newButtonText}</Text>
</TouchableOpacity>
</View>
);
}
export type { SearchHeaderProps };
@@ -0,0 +1,136 @@
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)" }}
>
<View>
<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 }}
>
<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>
);
};
@@ -0,0 +1,227 @@
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>
);
};
@@ -1,11 +1,31 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { Activity, useMemo, useState } from "react";
import { Alert, FlatList, RefreshControl, ScrollView, Text, View } from "react-native";
import {
Alert,
FlatList,
Platform,
RefreshControl,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { BookingListItem } from "@/components/booking-list-item/BookingListItem";
import { BookingModals } from "@/components/booking-modals/BookingModals";
import { EmptyScreen } from "@/components/EmptyScreen";
import { LoadingSpinner } from "@/components/LoadingSpinner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Text as UIText } from "@/components/ui/text";
import { useAuth } from "@/contexts/AuthContext";
import {
type BookingFilter,
@@ -100,6 +120,11 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
showRejectModal,
rejectReason,
setRejectReason,
showCancelModal,
cancelReason,
setCancelReason,
handleSubmitCancel,
handleCloseCancelModal,
selectedBooking,
setSelectedBooking,
handleBookingPress,
@@ -463,7 +488,49 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
}}
/>
{/* Action Modals */}
{/* Cancel Event AlertDialog for Android */}
{Platform.OS === "android" && (
<AlertDialog open={showCancelModal} onOpenChange={handleCloseCancelModal}>
<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={cancelReason}
onChangeText={setCancelReason}
autoFocus
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
/>
</View>
<AlertDialogFooter>
<AlertDialogCancel onPress={handleCloseCancelModal}>
<UIText>Nevermind</UIText>
</AlertDialogCancel>
<AlertDialogAction onPress={handleSubmitCancel}>
<UIText className="text-white">Cancel event</UIText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
};
@@ -0,0 +1,120 @@
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>
);
};
@@ -18,6 +18,17 @@ import { EmptyScreen } from "@/components/EmptyScreen";
import { FullScreenModal } from "@/components/FullScreenModal";
import { Header } from "@/components/Header";
import { LoadingSpinner } from "@/components/LoadingSpinner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Text as AlertDialogText } from "@/components/ui/text";
import {
useCreateSchedule,
useDeleteSchedule,
@@ -45,6 +56,7 @@ export function AvailabilityListScreen({
}: AvailabilityListScreenProps) {
const router = useRouter();
const [newScheduleName, setNewScheduleName] = useState("");
const [nameError, setNameError] = useState("");
const [showActionsModal, setShowActionsModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -103,11 +115,18 @@ export function AvailabilityListScreen({
style?: "destructive" | "cancel" | "default";
}[] = [];
if (!schedule.isDefault) {
options.push({ text: "Set as default", onPress: () => handleSetAsDefault(schedule) });
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) }
{
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);
@@ -218,12 +237,21 @@ export function AvailabilityListScreen({
const handleCreateNew = () => {
setNewScheduleName("");
setNameError("");
onShowCreateModalChange(true);
};
const handleCreateSchedule = async () => {
// Clear previous error
setNameError("");
if (!newScheduleName.trim()) {
Alert.alert("Error", "Please enter a schedule name");
// Use inline error for Android AlertDialog, Alert for others
if (Platform.OS === "android") {
setNameError("Please enter a schedule name");
} else {
Alert.alert("Error", "Please enter a schedule name");
}
return;
}
@@ -270,7 +298,10 @@ export function AvailabilityListScreen({
console.error("Failed to create schedule", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[AvailabilityListScreen] createSchedule failed", { message, stack });
console.debug("[AvailabilityListScreen] createSchedule failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to create schedule. Please try again.");
},
@@ -417,73 +448,132 @@ export function AvailabilityListScreen({
</Activity>
</Activity>
{/* Create Schedule Modal */}
<FullScreenModal
visible={showCreateModal}
animationType="fade"
onRequestClose={() => onShowCreateModalChange(false)}
>
<AppPressable
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => onShowCreateModalChange(false)}
{/* Create Schedule Modal - Android uses AlertDialog */}
{Platform.OS === "android" ? (
<AlertDialog open={showCreateModal} onOpenChange={onShowCreateModalChange}>
<AlertDialogContent>
<AlertDialogHeader className="items-start">
<AlertDialogTitle>
<AlertDialogText className="text-left text-lg font-semibold">
Add a new schedule
</AlertDialogText>
</AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogText className="text-left text-sm text-muted-foreground">
Create a new availability schedule.
</AlertDialogText>
</AlertDialogDescription>
</AlertDialogHeader>
{/* Name Input */}
<View>
<AlertDialogText className="mb-2 text-sm font-medium">Name</AlertDialogText>
<TextInput
className={`rounded-md border bg-white px-3 py-2.5 text-base text-[#111827] ${
nameError ? "border-red-500" : "border-[#D1D5DB]"
}`}
placeholder="Working Hours"
placeholderTextColor="#9CA3AF"
value={newScheduleName}
onChangeText={(text) => {
setNewScheduleName(text);
if (nameError) setNameError("");
}}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateSchedule}
/>
{nameError ? (
<AlertDialogText className="mt-1 text-sm text-red-500">{nameError}</AlertDialogText>
) : null}
</View>
<AlertDialogFooter>
<AlertDialogCancel
onPress={() => {
onShowCreateModalChange(false);
setNewScheduleName("");
setNameError("");
}}
disabled={creating}
>
<AlertDialogText>Cancel</AlertDialogText>
</AlertDialogCancel>
<AlertDialogAction onPress={handleCreateSchedule} disabled={creating}>
<AlertDialogText className="text-white">Continue</AlertDialogText>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<FullScreenModal
visible={showCreateModal}
animationType="fade"
onRequestClose={() => onShowCreateModalChange(false)}
>
<AppPressable
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={shadows.xl()}
onPress={() => onShowCreateModalChange(false)}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<Text className="text-2xl font-semibold text-[#111827]">Add a new schedule</Text>
</View>
{/* Content */}
<View className="px-8 pb-6">
<View className="mb-1">
<Text className="mb-2 text-sm font-medium text-[#374151]">Name</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
placeholder="Working Hours"
placeholderTextColor="#9CA3AF"
value={newScheduleName}
onChangeText={setNewScheduleName}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateSchedule}
/>
<AppPressable
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={shadows.xl()}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<Text className="text-2xl font-semibold text-[#111827]">Add a new schedule</Text>
</View>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<AppPressable
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
onShowCreateModalChange(false);
setNewScheduleName("");
}}
disabled={creating}
>
<Text className="text-base font-medium text-[#374151]">Close</Text>
</AppPressable>
<AppPressable
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${
creating ? "opacity-60" : ""
}`}
onPress={handleCreateSchedule}
disabled={creating}
>
<Text className="text-base font-medium text-white">Continue</Text>
</AppPressable>
{/* Content */}
<View className="px-8 pb-6">
<View className="mb-1">
<Text className="mb-2 text-sm font-medium text-[#374151]">Name</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
placeholder="Working Hours"
placeholderTextColor="#9CA3AF"
value={newScheduleName}
onChangeText={setNewScheduleName}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateSchedule}
/>
</View>
</View>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<AppPressable
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
onShowCreateModalChange(false);
setNewScheduleName("");
}}
disabled={creating}
>
<Text className="text-base font-medium text-[#374151]">Close</Text>
</AppPressable>
<AppPressable
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${
creating ? "opacity-60" : ""
}`}
onPress={handleCreateSchedule}
disabled={creating}
>
<Text className="text-base font-medium text-white">Continue</Text>
</AppPressable>
</View>
</View>
</AppPressable>
</AppPressable>
</AppPressable>
</FullScreenModal>
</FullScreenModal>
)}
{/* Schedule Actions Modal */}
<FullScreenModal
@@ -0,0 +1,846 @@
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>
</>
);
}
@@ -52,8 +52,8 @@ const formatTime12Hour = (dateString: string): string => {
return `${hour12}:${minStr}${period}`;
};
// Get timezone from date string
const getTimezone = (_dateString: string): string => {
// Get user's local timezone for display
const getTimezone = (): string => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return timeZone || "";
};
@@ -434,7 +434,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
const endTime = booking.end || booking.endTime || "";
const dateFormatted = formatDateFull(startTime);
const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
const timezone = getTimezone(startTime);
const timezone = getTimezone();
const locationProvider = getLocationProvider(booking.location, booking.responses);
return (
@@ -607,8 +607,9 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
{booking.recurringEventId ||
(booking as { recurringBookingUid?: string }).recurringBookingUid ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Recurring Event</Text>
<Text className="text-base text-[#666]">Every 2 weeks for 6 occurrences</Text>
<Text className="text-base font-medium text-[#666]">
This is part of a recurring event
</Text>
</View>
) : null}
+156
View File
@@ -0,0 +1,156 @@
import { buttonTextVariants, buttonVariants } from "@/components/ui/button";
import { NativeOnlyAnimatedView } from "@/components/ui/native-only-animated-view";
import { TextClassContext } from "@/components/ui/text";
import { cn } from "@/lib/utils";
import * as AlertDialogPrimitive from "@rn-primitives/alert-dialog";
import * as React from "react";
import { Platform, View, type ViewProps } from "react-native";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
function AlertDialogOverlay({
className,
children,
...props
}: Omit<AlertDialogPrimitive.OverlayProps, "asChild"> &
React.RefAttributes<AlertDialogPrimitive.OverlayRef> & {
children?: React.ReactNode;
}) {
return (
<FullWindowOverlay>
<AlertDialogPrimitive.Overlay
className={cn(
"absolute bottom-0 left-0 right-0 top-0 z-50 flex items-center justify-center bg-black/50 p-2",
Platform.select({
web: "animate-in fade-in-0 fixed",
}),
className
)}
{...props}
>
<NativeOnlyAnimatedView
entering={FadeIn.duration(200).delay(50)}
exiting={FadeOut.duration(150)}
>
{children}
</NativeOnlyAnimatedView>
</AlertDialogPrimitive.Overlay>
</FullWindowOverlay>
);
}
function AlertDialogContent({
className,
portalHost,
...props
}: AlertDialogPrimitive.ContentProps &
React.RefAttributes<AlertDialogPrimitive.ContentRef> & {
portalHost?: string;
}) {
return (
<AlertDialogPortal hostName={portalHost}>
<AlertDialogOverlay>
<AlertDialogPrimitive.Content
className={cn(
"bg-background border-border z-50 flex w-full flex-col gap-4 rounded-lg border p-6 shadow-lg shadow-black/5",
Platform.select({
web: "max-w-[calc(100%-2rem)] animate-in fade-in-0 zoom-in-95 duration-200 sm:max-w-lg",
default: "max-w-[380px]",
}),
className
)}
{...props}
/>
</AlertDialogOverlay>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: ViewProps) {
return (
<TextClassContext.Provider value="text-center sm:text-left">
<View className={cn("flex flex-col gap-2", className)} {...props} />
</TextClassContext.Provider>
);
}
function AlertDialogFooter({ className, ...props }: ViewProps) {
return (
<View
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: AlertDialogPrimitive.TitleProps & React.RefAttributes<AlertDialogPrimitive.TitleRef>) {
return (
<AlertDialogPrimitive.Title
className={cn("text-foreground text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: AlertDialogPrimitive.DescriptionProps &
React.RefAttributes<AlertDialogPrimitive.DescriptionRef>) {
return (
<AlertDialogPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: AlertDialogPrimitive.ActionProps & React.RefAttributes<AlertDialogPrimitive.ActionRef>) {
return (
<TextClassContext.Provider value={buttonTextVariants()}>
<AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
</TextClassContext.Provider>
);
}
function AlertDialogCancel({
className,
...props
}: AlertDialogPrimitive.CancelProps & React.RefAttributes<AlertDialogPrimitive.CancelRef>) {
return (
<TextClassContext.Provider value={buttonTextVariants({ variant: "outline" })}>
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
</TextClassContext.Provider>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+74
View File
@@ -0,0 +1,74 @@
import type { LucideIcon } from "lucide-react-native";
import * as React from "react";
import { View, type ViewProps } from "react-native";
import { Icon } from "@/components/ui/icon";
import { Text, TextClassContext } from "@/components/ui/text";
import { cn } from "@/lib/utils";
function Alert({
className,
variant,
children,
icon,
iconClassName,
...props
}: ViewProps &
React.RefAttributes<View> & {
icon: LucideIcon;
variant?: "default" | "destructive";
iconClassName?: string;
}) {
return (
<TextClassContext.Provider
value={cn("text-sm text-foreground", variant === "destructive" && "text-destructive")}
>
<View
role="alert"
className={cn(
"bg-card border-border relative w-full rounded-lg border px-4 pb-2 pt-3.5",
className
)}
{...props}
>
<View className="absolute left-3.5 top-3">
<Icon
as={icon}
className={cn("size-4", variant === "destructive" && "text-destructive", iconClassName)}
/>
</View>
{children}
</View>
</TextClassContext.Provider>
);
}
function AlertTitle({
className,
...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
return (
<Text
className={cn("mb-1 ml-0.5 min-h-4 pl-6 font-medium leading-none tracking-tight", className)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
const textClass = React.useContext(TextClassContext);
return (
<Text
className={cn(
"text-muted-foreground ml-0.5 pb-1.5 pl-6 text-sm leading-relaxed",
textClass?.includes("text-destructive") && "text-destructive/90",
className
)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };
+108
View File
@@ -0,0 +1,108 @@
import { TextClassContext } from "@/components/ui/text";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { Platform, Pressable } from "react-native";
const buttonVariants = cva(
cn(
"group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none",
Platform.select({
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
})
),
{
variants: {
variant: {
default: cn(
"bg-primary active:bg-primary/90 shadow-sm shadow-black/5",
Platform.select({ web: "hover:bg-primary/90" })
),
destructive: cn(
"bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5",
Platform.select({
web: "hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
})
),
outline: cn(
"border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5",
Platform.select({
web: "hover:bg-accent dark:hover:bg-input/50",
})
),
secondary: cn(
"bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5",
Platform.select({ web: "hover:bg-secondary/80" })
),
ghost: cn(
"active:bg-accent dark:active:bg-accent/50",
Platform.select({ web: "hover:bg-accent dark:hover:bg-accent/50" })
),
link: "",
},
size: {
default: cn("h-10 px-4 py-2 sm:h-9", Platform.select({ web: "has-[>svg]:px-3" })),
sm: cn("h-9 gap-1.5 rounded-md px-3 sm:h-8", Platform.select({ web: "has-[>svg]:px-2.5" })),
lg: cn("h-11 rounded-md px-6 sm:h-10", Platform.select({ web: "has-[>svg]:px-4" })),
icon: "h-10 w-10 sm:h-9 sm:w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const buttonTextVariants = cva(
cn(
"text-foreground text-sm font-medium",
Platform.select({ web: "pointer-events-none transition-colors" })
),
{
variants: {
variant: {
default: "text-primary-foreground",
destructive: "text-white",
outline: cn(
"group-active:text-accent-foreground",
Platform.select({ web: "group-hover:text-accent-foreground" })
),
secondary: "text-secondary-foreground",
ghost: "group-active:text-accent-foreground",
link: cn(
"text-primary group-active:underline",
Platform.select({ web: "underline-offset-4 hover:underline group-hover:underline" })
),
},
size: {
default: "",
sm: "",
lg: "",
icon: "",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
type ButtonProps = React.ComponentProps<typeof Pressable> &
React.RefAttributes<typeof Pressable> &
VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
<Pressable
className={cn(props.disabled && "opacity-50", buttonVariants({ variant, size }), className)}
role="button"
{...props}
/>
</TextClassContext.Provider>
);
}
export { Button, buttonTextVariants, buttonVariants };
export type { ButtonProps };
+311
View File
@@ -0,0 +1,311 @@
import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu";
import { Check, ChevronDown, ChevronRight, ChevronUp } from "lucide-react-native";
import * as React from "react";
import {
Platform,
type StyleProp,
StyleSheet,
Text,
type TextProps,
View,
type ViewStyle,
} from "react-native";
import { FadeIn } from "react-native-reanimated";
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
import { Icon } from "@/components/ui/icon";
import { NativeOnlyAnimatedView } from "@/components/ui/native-only-animated-view";
import { TextClassContext } from "@/components/ui/text";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
function DropdownMenuSubTrigger({
className,
inset,
children,
iconClassName,
...props
}: DropdownMenuPrimitive.SubTriggerProps &
React.RefAttributes<DropdownMenuPrimitive.SubTriggerRef> & {
children?: React.ReactNode;
iconClassName?: string;
inset?: boolean;
}) {
const { open } = DropdownMenuPrimitive.useSubContext();
const icon = Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown;
return (
<TextClassContext.Provider
value={cn(
"text-sm select-none group-active:text-accent-foreground",
open && "text-accent-foreground"
)}
>
<DropdownMenuPrimitive.SubTrigger
className={cn(
"active:bg-accent group flex flex-row items-center rounded-sm px-2 py-2 sm:py-1.5",
Platform.select({
web: "focus:bg-accent focus:text-accent-foreground cursor-default outline-none [&_svg]:pointer-events-none",
}),
open && "bg-accent",
inset && "pl-8"
)}
{...props}
>
{children}
<Icon as={icon} className={cn("text-foreground ml-auto size-4 shrink-0", iconClassName)} />
</DropdownMenuPrimitive.SubTrigger>
</TextClassContext.Provider>
);
}
function DropdownMenuSubContent({
className,
...props
}: DropdownMenuPrimitive.SubContentProps &
React.RefAttributes<DropdownMenuPrimitive.SubContentRef>) {
return (
<NativeOnlyAnimatedView entering={FadeIn}>
<DropdownMenuPrimitive.SubContent
className={cn(
"bg-popover border-border overflow-hidden rounded-md border p-1 shadow-lg shadow-black/5",
Platform.select({
web: "animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 fade-in-0 data-[state=closed]:zoom-out-95 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin) z-50 min-w-[8rem]",
}),
className
)}
{...props}
/>
</NativeOnlyAnimatedView>
);
}
const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
function DropdownMenuContent({
className,
overlayClassName,
overlayStyle,
portalHost,
...props
}: DropdownMenuPrimitive.ContentProps &
React.RefAttributes<DropdownMenuPrimitive.ContentRef> & {
overlayStyle?: StyleProp<ViewStyle>;
overlayClassName?: string;
portalHost?: string;
}) {
return (
<DropdownMenuPrimitive.Portal hostName={portalHost}>
<FullWindowOverlay>
<DropdownMenuPrimitive.Overlay
style={Platform.select({
web: overlayStyle ?? undefined,
native: overlayStyle
? StyleSheet.flatten([
StyleSheet.absoluteFill,
overlayStyle as typeof StyleSheet.absoluteFill,
])
: StyleSheet.absoluteFill,
})}
className={overlayClassName}
>
<NativeOnlyAnimatedView entering={FadeIn}>
<TextClassContext.Provider value="text-popover-foreground">
<DropdownMenuPrimitive.Content
className={cn(
"bg-popover border-border min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg shadow-black/5",
Platform.select({
web: cn(
"animate-in fade-in-0 zoom-in-95 max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) z-50 cursor-default",
props.side === "bottom" && "slide-in-from-top-2",
props.side === "top" && "slide-in-from-bottom-2"
),
}),
className
)}
{...props}
/>
</TextClassContext.Provider>
</NativeOnlyAnimatedView>
</DropdownMenuPrimitive.Overlay>
</FullWindowOverlay>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuItem({
className,
inset,
variant,
...props
}: DropdownMenuPrimitive.ItemProps &
React.RefAttributes<DropdownMenuPrimitive.ItemRef> & {
className?: string;
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<TextClassContext.Provider
value={cn(
"select-none text-sm text-popover-foreground group-active:text-popover-foreground",
variant === "destructive" && "text-destructive group-active:text-destructive"
)}
>
<DropdownMenuPrimitive.Item
className={cn(
"active:bg-accent group relative flex flex-row items-center gap-2 rounded-sm px-2 py-2 sm:py-1.5",
Platform.select({
web: cn(
"focus:bg-accent focus:text-accent-foreground cursor-default outline-none data-[disabled]:pointer-events-none",
variant === "destructive" && "focus:bg-destructive/10 dark:focus:bg-destructive/20"
),
}),
variant === "destructive" && "active:bg-destructive/10 dark:active:bg-destructive/20",
props.disabled && "opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
</TextClassContext.Provider>
);
}
function DropdownMenuCheckboxItem({
className,
children,
...props
}: DropdownMenuPrimitive.CheckboxItemProps &
React.RefAttributes<DropdownMenuPrimitive.CheckboxItemRef> & {
children?: React.ReactNode;
}) {
return (
<TextClassContext.Provider value="text-sm text-popover-foreground select-none group-active:text-accent-foreground">
<DropdownMenuPrimitive.CheckboxItem
className={cn(
"active:bg-accent group relative flex flex-row items-center gap-2 rounded-sm py-2 pl-8 pr-2 sm:py-1.5",
Platform.select({
web: "focus:bg-accent focus:text-accent-foreground cursor-default outline-none data-[disabled]:pointer-events-none",
}),
props.disabled && "opacity-50",
className
)}
{...props}
>
<View className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Icon
as={Check}
className={cn(
"text-foreground size-4",
Platform.select({ web: "pointer-events-none" })
)}
/>
</DropdownMenuPrimitive.ItemIndicator>
</View>
{children}
</DropdownMenuPrimitive.CheckboxItem>
</TextClassContext.Provider>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: DropdownMenuPrimitive.RadioItemProps &
React.RefAttributes<DropdownMenuPrimitive.RadioItemRef> & {
children?: React.ReactNode;
}) {
return (
<TextClassContext.Provider value="text-sm text-popover-foreground select-none group-active:text-accent-foreground">
<DropdownMenuPrimitive.RadioItem
className={cn(
"active:bg-accent group relative flex flex-row items-center gap-2 rounded-sm py-2 pl-8 pr-2 sm:py-1.5",
Platform.select({
web: "focus:bg-accent focus:text-accent-foreground cursor-default outline-none data-[disabled]:pointer-events-none",
}),
props.disabled && "opacity-50",
className
)}
{...props}
>
<View className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<View className="bg-foreground h-2 w-2 rounded-full" />
</DropdownMenuPrimitive.ItemIndicator>
</View>
{children}
</DropdownMenuPrimitive.RadioItem>
</TextClassContext.Provider>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: DropdownMenuPrimitive.LabelProps &
React.RefAttributes<DropdownMenuPrimitive.LabelRef> & {
className?: string;
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
className={cn(
"text-foreground px-2 py-2 text-sm font-medium sm:py-1.5",
inset && "pl-8",
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: DropdownMenuPrimitive.SeparatorProps & React.RefAttributes<DropdownMenuPrimitive.SeparatorRef>) {
return (
<DropdownMenuPrimitive.Separator
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: TextProps & React.RefAttributes<Text>) {
return (
<Text
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+54
View File
@@ -0,0 +1,54 @@
import type { LucideIcon, LucideProps } from "lucide-react-native";
import { cssInterop } from "nativewind";
import { cn } from "@/lib/utils";
type IconProps = LucideProps & {
as: LucideIcon;
};
function IconImpl({ as: IconComponent, ...props }: IconProps) {
return <IconComponent {...props} />;
}
cssInterop(IconImpl, {
className: {
target: "style",
nativeStyleToProp: {
height: "size",
width: "size",
},
},
});
/**
* A wrapper component for Lucide icons with Nativewind `className` support via `cssInterop`.
*
* This component allows you to render any Lucide icon while applying utility classes
* using `nativewind`. It avoids the need to wrap or configure each icon individually.
*
* @component
* @example
* ```tsx
* import { ArrowRight } from 'lucide-react-native';
* import { Icon } from '@/components/ui/icon';
*
* <Icon as={ArrowRight} className="text-red-500" size={16} />
* ```
*
* @param {LucideIcon} as - The Lucide icon component to render.
* @param {string} className - Utility classes to style the icon using Nativewind.
* @param {number} size - Icon size (defaults to 14).
* @param {...LucideProps} ...props - Additional Lucide icon props passed to the "as" icon.
*/
function Icon({ as: IconComponent, className, size = 14, ...props }: IconProps) {
return (
<IconImpl
as={IconComponent}
className={cn("text-foreground", className)}
size={size}
{...props}
/>
);
}
export { Icon };
@@ -0,0 +1,23 @@
import { Platform } from "react-native";
import Animated from "react-native-reanimated";
/**
* This component is used to wrap animated views that should only be animated on native.
* @param props - The props for the animated view.
* @returns The animated view if the platform is native, otherwise the children.
* @example
* <NativeOnlyAnimatedView entering={FadeIn} exiting={FadeOut}>
* <Text>I am only animated on native</Text>
* </NativeOnlyAnimatedView>
*/
function NativeOnlyAnimatedView(
props: React.ComponentProps<typeof Animated.View> & React.RefAttributes<Animated.View>
) {
if (Platform.OS === "web") {
return <>{props.children as React.ReactNode}</>;
} else {
return <Animated.View {...props} />;
}
}
export { NativeOnlyAnimatedView };
+89
View File
@@ -0,0 +1,89 @@
import { cn } from "@/lib/utils";
import * as Slot from "@rn-primitives/slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Platform, Text as RNText, type Role } from "react-native";
const textVariants = cva(
cn(
"text-foreground text-base",
Platform.select({
web: "select-text",
})
),
{
variants: {
variant: {
default: "",
h1: cn(
"text-center text-4xl font-extrabold tracking-tight",
Platform.select({ web: "scroll-m-20 text-balance" })
),
h2: cn(
"border-border border-b pb-2 text-3xl font-semibold tracking-tight",
Platform.select({ web: "scroll-m-20 first:mt-0" })
),
h3: cn("text-2xl font-semibold tracking-tight", Platform.select({ web: "scroll-m-20" })),
h4: cn("text-xl font-semibold tracking-tight", Platform.select({ web: "scroll-m-20" })),
p: "mt-3 leading-7 sm:mt-6",
blockquote: "mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6",
code: cn(
"bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold"
),
lead: "text-muted-foreground text-xl",
large: "text-lg font-semibold",
small: "text-sm font-medium leading-none",
muted: "text-muted-foreground text-sm",
},
},
defaultVariants: {
variant: "default",
},
}
);
type TextVariantProps = VariantProps<typeof textVariants>;
type TextVariant = NonNullable<TextVariantProps["variant"]>;
const ROLE: Partial<Record<TextVariant, Role>> = {
h1: "heading",
h2: "heading",
h3: "heading",
h4: "heading",
blockquote: Platform.select({ web: "blockquote" as Role }),
code: Platform.select({ web: "code" as Role }),
};
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
h1: "1",
h2: "2",
h3: "3",
h4: "4",
};
const TextClassContext = React.createContext<string | undefined>(undefined);
function Text({
className,
asChild = false,
variant = "default",
...props
}: React.ComponentProps<typeof RNText> &
TextVariantProps &
React.RefAttributes<RNText> & {
asChild?: boolean;
}) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}
role={variant ? ROLE[variant] : undefined}
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
{...props}
/>
);
}
export { Text, TextClassContext };
+59
View File
@@ -0,0 +1,59 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
interface ToastProps {
visible: boolean;
message: string;
type: "success" | "error";
}
/**
* Toast notification component that displays at the bottom of the screen.
* Use with the useToast hook for state management.
*
* @example
* ```tsx
* const { toast, showToast } = useToast();
*
* return (
* <>
* <Button onPress={() => showToast("Done!")} />
* <Toast {...toast} />
* </>
* );
* ```
*/
export function Toast({ visible, message, type }: ToastProps) {
const insets = useSafeAreaInsets();
if (!visible) return null;
return (
<View
style={{
position: "absolute",
bottom: Math.max(insets.bottom, 16) + 84, // 84px for tab bar + padding
left: 16,
right: 16,
}}
pointerEvents="none"
>
<View
className={`flex-row items-center rounded-lg px-4 py-3 shadow-lg ${
type === "error" ? "bg-red-600" : "bg-gray-800"
}`}
>
<Ionicons
name={type === "error" ? "close-circle" : "checkmark-circle"}
size={20}
color="white"
style={{ marginRight: 8 }}
/>
<Text className="flex-1 text-sm font-medium text-white">{message}</Text>
</View>
</View>
);
}
export type { ToastProps };
+42 -2
View File
@@ -4,8 +4,48 @@
@layer base {
:root {
--background: #ffffff;
--foreground: #000000;
--background: 0 0% 100%;
--foreground: 0 0% 0%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
input:focus {
+2
View File
@@ -63,6 +63,8 @@ export {
useSetScheduleAsDefault,
useUpdateSchedule,
} from "./useSchedules";
// Toast hook
export { type ToastState, type ToastType, useToast } from "./useToast";
// User Profile hooks
export {
type UpdateUserProfileInput,
+67 -2
View File
@@ -1,6 +1,6 @@
import type { useRouter } from "expo-router";
import { useState } from "react";
import { Alert } from "react-native";
import { Alert, Platform } from "react-native";
import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
@@ -61,6 +61,11 @@ export const useBookingActions = ({
const [rejectBooking, setRejectBooking] = useState<Booking | null>(null);
const [rejectReason, setRejectReason] = useState("");
// Cancel modal state (for Android AlertDialog)
const [showCancelModal, setShowCancelModal] = useState(false);
const [cancelBooking, setCancelBooking] = useState<Booking | null>(null);
const [cancelReason, setCancelReason] = useState("");
// Selected booking for actions modal
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
@@ -224,9 +229,59 @@ export const useBookingActions = ({
};
/**
* Show alert and cancel booking (iOS Alert.prompt pattern)
* Open cancel modal for Android, use Alert.prompt for iOS
*/
const handleOpenCancelModal = (booking: Booking) => {
setCancelBooking(booking);
setCancelReason("");
setShowCancelModal(true);
};
/**
* Submit cancel with reason (used by Android AlertDialog)
*/
const handleSubmitCancel = () => {
if (!cancelBooking) return;
const reason = cancelReason.trim() || "Event cancelled by host";
cancelMutation(
{ uid: cancelBooking.uid, reason },
{
onSuccess: () => {
setShowCancelModal(false);
setCancelBooking(null);
setCancelReason("");
Alert.alert("Success", "Event cancelled successfully");
},
onError: (_error) => {
console.error("Failed to cancel booking");
showErrorAlert("Error", "Failed to cancel event. Please try again.");
},
}
);
};
/**
* Close cancel modal and reset state
*/
const handleCloseCancelModal = () => {
setShowCancelModal(false);
setCancelBooking(null);
setCancelReason("");
};
/**
* Show alert and cancel booking (iOS Alert.prompt pattern, Android opens modal)
*/
const handleCancelBooking = (booking: Booking) => {
// For Android, open the cancel modal with AlertDialog
if (Platform.OS === "android") {
handleOpenCancelModal(booking);
return;
}
// For iOS, use native Alert.prompt
Alert.alert("Cancel Event", `Are you sure you want to cancel "${booking.title}"?`, [
{ text: "Cancel", style: "cancel" },
{
@@ -415,6 +470,13 @@ export const useBookingActions = ({
rejectReason,
setRejectReason,
// Cancel state (for Android AlertDialog)
showCancelModal,
setShowCancelModal,
cancelBooking,
cancelReason,
setCancelReason,
// Selected booking state
selectedBooking,
setSelectedBooking,
@@ -426,6 +488,9 @@ export const useBookingActions = ({
handleRescheduleWithValues,
handleCloseRescheduleModal,
handleCancelBooking,
handleOpenCancelModal,
handleSubmitCancel,
handleCloseCancelModal,
handleConfirmBooking,
handleOpenRejectModal,
handleRejectBooking,
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from "react";
type ToastType = "success" | "error";
interface ToastState {
visible: boolean;
message: string;
type: ToastType;
}
/**
* Hook for managing toast notifications with auto-dismiss functionality.
*
* @param autoDismissMs - Time in milliseconds before the toast auto-dismisses (default: 2500)
* @returns Object containing toast state, showToast function, and hideToast function
*
* @example
* ```tsx
* const { toast, showToast } = useToast();
*
* // Show success toast
* showToast("Operation completed successfully");
*
* // Show error toast
* showToast("Something went wrong", "error");
*
* // Render toast
* <Toast {...toast} />
* ```
*/
export function useToast(autoDismissMs = 2500) {
const [toast, setToast] = useState<ToastState>({
visible: false,
message: "",
type: "success",
});
const showToast = useCallback((message: string, type: ToastType = "success") => {
setToast({ visible: true, message, type });
}, []);
const hideToast = useCallback(() => {
setToast({ visible: false, message: "", type: "success" });
}, []);
useEffect(() => {
if (toast.visible) {
const timer = setTimeout(hideToast, autoDismissMs);
return () => clearTimeout(timer);
}
}, [toast, autoDismissMs, hideToast]);
return { toast, showToast, hideToast };
}
export type { ToastState, ToastType };
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1 -1
View File
@@ -20,4 +20,4 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
return context.resolveRequest(context, moduleName, platform);
};
module.exports = withNativeWind(config, { input: "./global.css" });
module.exports = withNativeWind(config, { input: "./global.css", inlineRem: 16 });
+9 -1
View File
@@ -55,9 +55,14 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
"@rn-primitives/alert-dialog": "1.2.0",
"@rn-primitives/dropdown-menu": "1.2.0",
"@rn-primitives/slot": "1.2.0",
"@tanstack/react-query": "5.62.0",
"@tanstack/react-query-persist-client": "5.62.0",
"base64-js": "1.5.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"expo": "55.0.0-canary-20251230-fc48ddc",
"expo-auth-session": "7.0.11-canary-20251230-fc48ddc",
"expo-clipboard": "9.0.0-canary-20251230-fc48ddc",
@@ -74,6 +79,7 @@
"expo-splash-screen": "31.0.14-canary-20251230-fc48ddc",
"expo-status-bar": "3.0.10-canary-20251230-fc48ddc",
"expo-web-browser": "15.0.11-canary-20251230-fc48ddc",
"lucide-react-native": "0.562.0",
"nativewind": "4.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -84,7 +90,9 @@
"react-native-screens": "4.19.0",
"react-native-svg": "15.12.1",
"react-native-web": "0.21.2",
"react-native-worklets": "0.7.1"
"react-native-worklets": "0.7.1",
"tailwind-merge": "3.4.0",
"tailwindcss-animate": "1.0.7"
},
"private": true,
"devDependencies": {
+39 -1
View File
@@ -6,6 +6,39 @@ module.exports = {
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
cal: {
text: {
DEFAULT: "#333333",
@@ -35,10 +68,15 @@ module.exports = {
},
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
spacing: {
18: "4.5rem",
},
},
},
plugins: [],
plugins: [require("tailwindcss-animate")],
};
+34
View File
@@ -0,0 +1,34 @@
/**
* Determines if the error should be displayed to the user.
* Filters out authentication errors (which are handled by auth redirect)
* and only shows errors in development mode.
*
* @param queryError - The error from a query, if any
* @param context - A description of what was being loaded (e.g., "event types", "bookings")
* @returns A user-friendly error message or null if the error should not be displayed
*
* @example
* ```tsx
* const { error: queryError } = useEventTypes();
* const error = getDisplayError(queryError, "event types");
*
* if (error) {
* return <ErrorView error={error} />;
* }
* ```
*/
export function getDisplayError(
queryError: Error | null | undefined,
context: string
): string | null {
if (!queryError) return null;
const isAuthError =
queryError.message?.includes("Authentication") ||
queryError.message?.includes("sign in") ||
queryError.message?.includes("401");
if (isAuthError) return null;
return __DEV__ ? `Failed to load ${context}.` : null;
}