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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user