diff --git a/companion/app/(tabs)/(availability)/_layout.tsx b/companion/app/(tabs)/(availability)/_layout.tsx index ca5262062875ac..186d570bd3b86b 100644 --- a/companion/app/(tabs)/(availability)/_layout.tsx +++ b/companion/app/(tabs)/(availability)/_layout.tsx @@ -1,9 +1,9 @@ import { Stack } from "expo-router"; -export default function EventTypesLayout() { +export default function AvailabilityLayout() { return ( - + ); } diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx index 650b0d5308ec44..25c7f46ce1fb0a 100644 --- a/companion/app/(tabs)/(availability)/index.tsx +++ b/companion/app/(tabs)/(availability)/index.tsx @@ -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(); diff --git a/companion/app/(tabs)/(event-types)/_layout.tsx b/companion/app/(tabs)/(event-types)/_layout.tsx index ca5262062875ac..710077f404ed93 100644 --- a/companion/app/(tabs)/(event-types)/_layout.tsx +++ b/companion/app/(tabs)/(event-types)/_layout.tsx @@ -3,7 +3,7 @@ import { Stack } from "expo-router"; export default function EventTypesLayout() { return ( - + ); } diff --git a/companion/app/(tabs)/(event-types)/index.ios.tsx b/companion/app/(tabs)/(event-types)/index.ios.tsx index 3487ce603f6cf5..930ebf24f57633 100644 --- a/companion/app/(tabs)/(event-types)/index.ios.tsx +++ b/companion/app/(tabs)/(event-types)/index.ios.tsx @@ -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(); diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx index 25074190362706..4fd61d6038e0e3 100644 --- a/companion/app/(tabs)/(event-types)/index.tsx +++ b/companion/app/(tabs)/(event-types)/index.tsx @@ -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() { - {/* Create Event Type Modal */} - - { + if (!open) handleCloseCreateModal(); + }} + > + + + + + Add a new event type + + + + + Set up event types to offer different types of meetings. + + + + + {/* Title Input */} + + Title + { + setNewEventTitle(text); + if (titleError) setTitleError(""); + }} + autoFocus + autoCapitalize="words" + returnKeyType="done" + onSubmitEditing={handleCreateEventType} + /> + {titleError ? ( + + {titleError} + + ) : null} + + + + + Cancel + + + Continue + + + + + ) : ( + e.stopPropagation()} - style={shadows.xl()} + onPress={handleCloseCreateModal} > - {/* Header */} - - - Add a new event type - - - Set up event types to offer different types of meetings. - - + e.stopPropagation()} + style={shadows.xl()} + > + {/* Header */} + + + Add a new event type + + + Set up event types to offer different types of meetings. + + - {/* Content */} - - {/* Title */} - - Title - + {/* Content */} + + {/* Title */} + + Title + + - - {/* Footer */} - - - - Close - - - Continue - + {/* Footer */} + + + + Close + + + Continue + + - + - - + + )} {/* Action Modal for Web Platform */} {content} + ); } diff --git a/companion/bun.lock b/companion/bun.lock index 545bcd8a98b35f..61803209ddd514 100644 --- a/companion/bun.lock +++ b/companion/bun.lock @@ -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=="], diff --git a/companion/components.json b/companion/components.json new file mode 100644 index 00000000000000..a45eb48ecf5e0e --- /dev/null +++ b/companion/components.json @@ -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" + } +} diff --git a/companion/components/ScreenWrapper.tsx b/companion/components/ScreenWrapper.tsx new file mode 100644 index 00000000000000..1e4c7a47972a48 --- /dev/null +++ b/companion/components/ScreenWrapper.tsx @@ -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 + * + * + * + * ``` + */ +export function ScreenWrapper({ + loading, + error, + onRetry, + errorTitle = "Unable to load data", + isEmpty, + emptyProps, + showHeader = true, + children, +}: ScreenWrapperProps) { + if (loading) { + return ( + + {showHeader &&
} + + + + + ); + } + + if (error) { + return ( + + {showHeader &&
} + + + + {errorTitle} + + {error} + {onRetry && ( + + Retry + + )} + + + ); + } + + if (isEmpty && emptyProps) { + return ( + + {showHeader &&
} + + + + + ); + } + + return ( + + {showHeader &&
} + {children} + + ); +} + +export type { EmptyStateConfig, ScreenWrapperProps }; diff --git a/companion/components/SearchHeader.tsx b/companion/components/SearchHeader.tsx new file mode 100644 index 00000000000000..4ae12e51912700 --- /dev/null +++ b/companion/components/SearchHeader.tsx @@ -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 + * + * ``` + */ +export function SearchHeader({ + searchQuery, + onSearchChange, + placeholder = "Search", + onNewPress, + newButtonText = "New", +}: SearchHeaderProps) { + return ( + + + + + {newButtonText} + + + ); +} + +export type { SearchHeaderProps }; diff --git a/companion/components/availability-list-item/AvailabilityListItem.android.tsx b/companion/components/availability-list-item/AvailabilityListItem.android.tsx new file mode 100644 index 00000000000000..74be9a9b2f487e --- /dev/null +++ b/companion/components/availability-list-item/AvailabilityListItem.android.tsx @@ -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 ( + + + handleSchedulePress(schedule)} + className="mr-4 flex-1" + android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }} + > + + + + + + + + {/* Dropdown Menu */} + + + + + + + + + {scheduleActions.map((action, index) => ( + + {/* Add separator before destructive actions */} + {index === destructiveStartIndex && destructiveStartIndex > 0 && ( + + )} + + + + {action.label} + + + + ))} + + + + + ); +}; diff --git a/companion/components/booking-list-item/BookingListItem.android.tsx b/companion/components/booking-list-item/BookingListItem.android.tsx new file mode 100644 index 00000000000000..c52a67629ea1c9 --- /dev/null +++ b/companion/components/booking-list-item/BookingListItem.android.tsx @@ -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 = ({ + 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 ( + + onPress(booking)} + style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }} + className="active:bg-cal-bg-secondary" + android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }} + > + + + + + + + + + + + {/* Dropdown Menu - only show when there are visible actions */} + {visibleActions.length > 0 && ( + + + + + + + + + {visibleActions.map((action, index) => ( + + {/* Add separator before destructive actions */} + {index === destructiveStartIndex && destructiveStartIndex > 0 && ( + + )} + + + + {action.label} + + + + ))} + + + )} + + + ); +}; diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx index d49bf7ad410379..a16572c9033bb5 100644 --- a/companion/components/booking-list-screen/BookingListScreen.tsx +++ b/companion/components/booking-list-screen/BookingListScreen.tsx @@ -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 = ({ showRejectModal, rejectReason, setRejectReason, + showCancelModal, + cancelReason, + setCancelReason, + handleSubmitCancel, + handleCloseCancelModal, selectedBooking, setSelectedBooking, handleBookingPress, @@ -463,7 +488,49 @@ export const BookingListScreen: React.FC = ({ }} /> - {/* Action Modals */} + {/* Cancel Event AlertDialog for Android */} + {Platform.OS === "android" && ( + + + + + Cancel event + + + + Cancellation reason will be shared with guests + + + + + {/* Reason Input */} + + Reason for cancellation + + + + + + Nevermind + + + Cancel event + + + + + )} ); }; diff --git a/companion/components/event-type-list-item/EventTypeListItem.android.tsx b/companion/components/event-type-list-item/EventTypeListItem.android.tsx new file mode 100644 index 00000000000000..516760c2974818 --- /dev/null +++ b/companion/components/event-type-list-item/EventTypeListItem.android.tsx @@ -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 ( + + + handleEventTypePress(item)} + className="mr-4 flex-1" + android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }} + > + + + + + + + {/* Dropdown Menu - Single Button */} + + + + + + + + + {/* Preview & Copy Actions */} + handlePreview(item)}> + + Preview + + + handleCopyLink(item)}> + + Copy link + + + + + {/* Edit & Duplicate Actions */} + onEdit?.(item)}> + + Edit + + + onDuplicate?.(item)}> + + Duplicate + + + + + {/* Delete Action - Destructive */} + onDelete?.(item)}> + + Delete + + + + + + ); +}; diff --git a/companion/components/screens/AvailabilityListScreen.tsx b/companion/components/screens/AvailabilityListScreen.tsx index 42c0ec17832b9c..ab00cbc1661d5a 100644 --- a/companion/components/screens/AvailabilityListScreen.tsx +++ b/companion/components/screens/AvailabilityListScreen.tsx @@ -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(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({ - {/* Create Schedule Modal */} - onShowCreateModalChange(false)} - > - onShowCreateModalChange(false)} + {/* Create Schedule Modal - Android uses AlertDialog */} + {Platform.OS === "android" ? ( + + + + + + Add a new schedule + + + + + Create a new availability schedule. + + + + + {/* Name Input */} + + Name + { + setNewScheduleName(text); + if (nameError) setNameError(""); + }} + autoFocus + autoCapitalize="words" + returnKeyType="done" + onSubmitEditing={handleCreateSchedule} + /> + {nameError ? ( + {nameError} + ) : null} + + + + { + onShowCreateModalChange(false); + setNewScheduleName(""); + setNameError(""); + }} + disabled={creating} + > + Cancel + + + Continue + + + + + ) : ( + onShowCreateModalChange(false)} > e.stopPropagation()} - style={shadows.xl()} + onPress={() => onShowCreateModalChange(false)} > - {/* Header */} - - Add a new schedule - + e.stopPropagation()} + style={shadows.xl()} + > + {/* Header */} + + Add a new schedule + - {/* Content */} - - - Name - + {/* Content */} + + + Name + + - - {/* Footer */} - - - { - onShowCreateModalChange(false); - setNewScheduleName(""); - }} - disabled={creating} - > - Close - - - Continue - + {/* Footer */} + + + { + onShowCreateModalChange(false); + setNewScheduleName(""); + }} + disabled={creating} + > + Close + + + Continue + + - + - - + + )} {/* Schedule Actions Modal */} { + 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) => { + // 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(null); + const [error, setError] = useState(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 ( + + + Loading booking... + + ); + } + + if (error || !booking) { + return ( + + + + {error || "Booking not found"} + + router.back()}> + Go Back + + + ); + } + + 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 */} + ( + + + + + + + + + {dropdownActions.map((action, index) => ( + + {/* Add separator before destructive actions */} + {index === destructiveStartIndex && destructiveStartIndex > 0 && ( + + )} + + + + {action.label} + + + + ))} + + + ), + }} + /> + + + {/* Title */} + + {booking.title} + + {dateFormatted} {timeFormatted} ({timezone}) + + + + {/* Who Section */} + + Who + {/* Show host from user field or hosts array */} + {booking.user || (booking.hosts && booking.hosts.length > 0) ? ( + + {booking.user ? ( + + + + {getInitials(booking.user.name)} + + + + + + {booking.user.name} + + + host + + + {booking.user.email} + + + ) : booking.hosts && booking.hosts.length > 0 ? ( + booking.hosts.map((host, hostIndex) => ( + 0 ? "mt-4" : ""}`} + > + + + {getInitials(host.name || "Host")} + + + + + + {host.name || "Host"} + + + host + + + {host.email && {host.email}} + + + )) + ) : null} + + ) : null} + {booking.attendees && booking.attendees.length > 0 ? ( + + {booking.attendees.map((attendee, index) => { + const isNoShow = + (attendee as { noShow?: boolean; absent?: boolean }).noShow === true || + (attendee as { noShow?: boolean; absent?: boolean }).absent === true; + return ( + 0 ? "mt-4" : ""}`} + > + + + {getInitials(attendee.name)} + + + + + + {attendee.name} + + {isNoShow && ( + + + + No-show + + + )} + + + {attendee.email} + + + + ); + })} + + ) : null} + + + {/* Where Section */} + {locationProvider ? ( + + Where + {locationProvider.url ? ( + + {locationProvider.iconUrl ? ( + + ) : null} + {locationProvider.label}: + + {locationProvider.url} + + + ) : ( + + {locationProvider.iconUrl ? ( + + ) : null} + {locationProvider.label} + + )} + + ) : null} + + {/* Recurring Event Section */} + {booking.recurringEventId || + (booking as { recurringBookingUid?: string }).recurringBookingUid ? ( + + + This is part of a recurring event + + + ) : null} + + {/* Description Section */} + {booking.description ? ( + + Description + {booking.description} + + ) : null} + + {/* Join Meeting Button */} + {locationProvider?.url ? ( + + {locationProvider.iconUrl ? ( + + ) : null} + + Join {locationProvider.label} + + + ) : null} + + + {/* Cancelling overlay */} + {isCancelling && ( + + + + + Cancelling booking... + + + + )} + + + {/* Cancel Event AlertDialog */} + + + + + Cancel event + + + + Cancellation reason will be shared with guests + + + + + {/* Reason Input */} + + Reason for cancellation + + + + + + Nevermind + + + Cancel event + + + + + + ); +} diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx index 8ec52ec1adf622..4c77990a86726d 100644 --- a/companion/components/screens/BookingDetailScreen.tsx +++ b/companion/components/screens/BookingDetailScreen.tsx @@ -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 ? ( - Recurring Event - Every 2 weeks for 6 occurrences + + This is part of a recurring event + ) : null} diff --git a/companion/components/ui/alert-dialog.tsx b/companion/components/ui/alert-dialog.tsx new file mode 100644 index 00000000000000..2fa55c8a35a043 --- /dev/null +++ b/companion/components/ui/alert-dialog.tsx @@ -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 & + React.RefAttributes & { + children?: React.ReactNode; + }) { + return ( + + + + {children} + + + + ); +} + +function AlertDialogContent({ + className, + portalHost, + ...props +}: AlertDialogPrimitive.ContentProps & + React.RefAttributes & { + portalHost?: string; + }) { + return ( + + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: ViewProps) { + return ( + + + + ); +} + +function AlertDialogFooter({ className, ...props }: ViewProps) { + return ( + + ); +} + +function AlertDialogTitle({ + className, + ...props +}: AlertDialogPrimitive.TitleProps & React.RefAttributes) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: AlertDialogPrimitive.DescriptionProps & + React.RefAttributes) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: AlertDialogPrimitive.ActionProps & React.RefAttributes) { + return ( + + + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: AlertDialogPrimitive.CancelProps & React.RefAttributes) { + return ( + + + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/companion/components/ui/alert.tsx b/companion/components/ui/alert.tsx new file mode 100644 index 00000000000000..fe5e0334173620 --- /dev/null +++ b/companion/components/ui/alert.tsx @@ -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 & { + icon: LucideIcon; + variant?: "default" | "destructive"; + iconClassName?: string; + }) { + return ( + + + + + + {children} + + + ); +} + +function AlertTitle({ + className, + ...props +}: React.ComponentProps & React.RefAttributes) { + return ( + + ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps & React.RefAttributes) { + const textClass = React.useContext(TextClassContext); + return ( + + ); +} + +export { Alert, AlertDescription, AlertTitle }; diff --git a/companion/components/ui/button.tsx b/companion/components/ui/button.tsx new file mode 100644 index 00000000000000..ed13f6de1cad4f --- /dev/null +++ b/companion/components/ui/button.tsx @@ -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 & + React.RefAttributes & + VariantProps; + +function Button({ className, variant, size, ...props }: ButtonProps) { + return ( + + + + ); +} + +export { Button, buttonTextVariants, buttonVariants }; +export type { ButtonProps }; diff --git a/companion/components/ui/dropdown-menu.tsx b/companion/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000000000..8b676c97150cb0 --- /dev/null +++ b/companion/components/ui/dropdown-menu.tsx @@ -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 & { + children?: React.ReactNode; + iconClassName?: string; + inset?: boolean; + }) { + const { open } = DropdownMenuPrimitive.useSubContext(); + const icon = Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown; + return ( + + + {children} + + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: DropdownMenuPrimitive.SubContentProps & + React.RefAttributes) { + return ( + + + + ); +} + +const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment; + +function DropdownMenuContent({ + className, + overlayClassName, + overlayStyle, + portalHost, + ...props +}: DropdownMenuPrimitive.ContentProps & + React.RefAttributes & { + overlayStyle?: StyleProp; + overlayClassName?: string; + portalHost?: string; + }) { + return ( + + + + + + + + + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant, + ...props +}: DropdownMenuPrimitive.ItemProps & + React.RefAttributes & { + className?: string; + inset?: boolean; + variant?: "default" | "destructive"; + }) { + return ( + + + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + ...props +}: DropdownMenuPrimitive.CheckboxItemProps & + React.RefAttributes & { + children?: React.ReactNode; + }) { + return ( + + + + + + + + {children} + + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: DropdownMenuPrimitive.RadioItemProps & + React.RefAttributes & { + children?: React.ReactNode; + }) { + return ( + + + + + + + + {children} + + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: DropdownMenuPrimitive.LabelProps & + React.RefAttributes & { + className?: string; + inset?: boolean; + }) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: DropdownMenuPrimitive.SeparatorProps & React.RefAttributes) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: TextProps & React.RefAttributes) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/companion/components/ui/icon.tsx b/companion/components/ui/icon.tsx new file mode 100644 index 00000000000000..810575878e86d9 --- /dev/null +++ b/companion/components/ui/icon.tsx @@ -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 ; +} + +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'; + * + * + * ``` + * + * @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 ( + + ); +} + +export { Icon }; diff --git a/companion/components/ui/native-only-animated-view.tsx b/companion/components/ui/native-only-animated-view.tsx new file mode 100644 index 00000000000000..7a1a471d08b721 --- /dev/null +++ b/companion/components/ui/native-only-animated-view.tsx @@ -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 + * + * I am only animated on native + * + */ +function NativeOnlyAnimatedView( + props: React.ComponentProps & React.RefAttributes +) { + if (Platform.OS === "web") { + return <>{props.children as React.ReactNode}; + } else { + return ; + } +} + +export { NativeOnlyAnimatedView }; diff --git a/companion/components/ui/text.tsx b/companion/components/ui/text.tsx new file mode 100644 index 00000000000000..b604a9d846e368 --- /dev/null +++ b/companion/components/ui/text.tsx @@ -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; + +type TextVariant = NonNullable; + +const ROLE: Partial> = { + 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> = { + h1: "1", + h2: "2", + h3: "3", + h4: "4", +}; + +const TextClassContext = React.createContext(undefined); + +function Text({ + className, + asChild = false, + variant = "default", + ...props +}: React.ComponentProps & + TextVariantProps & + React.RefAttributes & { + asChild?: boolean; + }) { + const textClass = React.useContext(TextClassContext); + const Component = asChild ? Slot.Text : RNText; + return ( + + ); +} + +export { Text, TextClassContext }; diff --git a/companion/components/ui/toast.tsx b/companion/components/ui/toast.tsx new file mode 100644 index 00000000000000..148bd241f98cd5 --- /dev/null +++ b/companion/components/ui/toast.tsx @@ -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 ( + * <> + *