diff --git a/companion/.env.example b/companion/.env.example index 36237cbab0..e1f34bd3cc 100644 --- a/companion/.env.example +++ b/companion/.env.example @@ -3,3 +3,26 @@ EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here +# =========================================== +# CACHE CONFIGURATION (all values in MINUTES) +# =========================================== + +# Default cache stale time for all queries (default: 5 minutes) +EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES=5 + +# Garbage collection time - how long to keep unused cache (default: 1440 minutes - 24 hours) +EXPO_PUBLIC_CACHE_GC_TIME_MINUTES=1440 + +# Bookings cache stale time (default: 5 minutes) +# After this time, data is considered stale and will refetch in background +EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES=5 + +# Event Types cache stale time (default: -1 = never stale) +# -1 means data only refreshes on mutations (create/update/delete) or manual pull-to-refresh +EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES=-1 + +# Schedules (Availability) cache stale time (default: -1 = never stale) +EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES=-1 + +# User Profile cache stale time (default: -1 = never stale) +EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES=-1 diff --git a/companion/app/(tabs)/availability.tsx b/companion/app/(tabs)/availability.tsx index 3d5c2d276e..d7331e7324 100644 --- a/companion/app/(tabs)/availability.tsx +++ b/companion/app/(tabs)/availability.tsx @@ -1,11 +1,10 @@ import { Ionicons } from "@expo/vector-icons"; -import { useRouter, useFocusEffect } from "expo-router"; -import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "expo-router"; +import React, { useState, useMemo } from "react"; import { View, Text, FlatList, - ActivityIndicator, RefreshControl, TouchableOpacity, ActionSheetIOS, @@ -14,83 +13,76 @@ import { Modal, TextInput, KeyboardAvoidingView, + ScrollView, } from "react-native"; import { CalComAPIService, Schedule } from "../../services/calcom"; import { Header } from "../../components/Header"; import { FullScreenModal } from "../../components/FullScreenModal"; +import { LoadingSpinner } from "../../components/LoadingSpinner"; +import { EmptyScreen } from "../../components/EmptyScreen"; +import { showErrorAlert } from "../../utils/alerts"; +import { offlineAwareRefresh } from "../../utils/network"; +import { + useSchedules, + useCreateSchedule, + useDeleteSchedule, + useDuplicateSchedule, + useSetScheduleAsDefault, +} from "../../hooks"; export default function Availability() { const router = useRouter(); - const [schedules, setSchedules] = useState([]); - const [filteredSchedules, setFilteredSchedules] = useState([]); const [searchQuery, setSearchQuery] = useState(""); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [newScheduleName, setNewScheduleName] = useState(""); - const [creating, setCreating] = useState(false); const [showActionsModal, setShowActionsModal] = useState(false); const [selectedSchedule, setSelectedSchedule] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); - const [deleting, setDeleting] = useState(false); - const fetchSchedules = async () => { - try { - setError(null); + // Use React Query hooks + const { + data: schedules = [], + isLoading: loading, + isFetching, + error: queryError, + refetch, + } = useSchedules(); - // Fetch all schedules - const allSchedules = await CalComAPIService.getSchedules(); + // Show refresh indicator when fetching + const refreshing = isFetching && !loading; - // Sort schedules: default first, then by name - const sortedSchedules = allSchedules.sort((a, b) => { - // Default schedule first - if (a.isDefault && !b.isDefault) return -1; - if (!a.isDefault && b.isDefault) return 1; - // Then sort by name alphabetically - return a.name.localeCompare(b.name); - }); + const { mutate: createScheduleMutation, isPending: creating } = useCreateSchedule(); + const { mutate: deleteScheduleMutation, isPending: deleting } = useDeleteSchedule(); + const { mutate: duplicateScheduleMutation } = useDuplicateSchedule(); + const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault(); - setSchedules(sortedSchedules); - setFilteredSchedules(sortedSchedules); - } catch (err) { - setError("Failed to load availability. Please check your API key and try again."); - } finally { - setLoading(false); - setRefreshing(false); + // Convert query error to string + // Don't show error UI for authentication errors (user will be redirected to login) + // Only show error UI in development mode for other errors + const isAuthError = + queryError?.message?.includes("Authentication") || + queryError?.message?.includes("sign in") || + queryError?.message?.includes("401"); + const error = queryError && !isAuthError && __DEV__ ? "Failed to load availability." : null; + + // Filter schedules based on search query + const filteredSchedules = useMemo(() => { + if (searchQuery.trim() === "") { + return schedules; } - }; + const searchLower = searchQuery.toLowerCase(); + return schedules.filter((schedule) => schedule.name.toLowerCase().includes(searchLower)); + }, [schedules, searchQuery]); - useEffect(() => { - fetchSchedules(); - }, []); + // Note: We don't use useFocusEffect here because schedules have Infinity stale time. + // Data only refreshes on mutations (create/update/delete) or manual pull-to-refresh. - // Refresh schedules when screen comes into focus - useFocusEffect( - useCallback(() => { - // Only refresh if not currently loading (to avoid duplicate calls) - if (!loading && !refreshing) { - fetchSchedules(); - } - }, [loading, refreshing]) - ); - - const onRefresh = () => { - setRefreshing(true); - fetchSchedules(); - }; + // Handle pull-to-refresh (offline-aware) + const onRefresh = () => offlineAwareRefresh(refetch); const handleSearch = (query: string) => { setSearchQuery(query); - if (query.trim() === "") { - setFilteredSchedules(schedules); - } else { - const filtered = schedules.filter((schedule) => - schedule.name.toLowerCase().includes(query.toLowerCase()) - ); - setFilteredSchedules(filtered); - } }; const handleScheduleLongPress = (schedule: Schedule) => { @@ -151,22 +143,20 @@ export default function Availability() { ); }; - const handleSetAsDefault = async (schedule: Schedule) => { - try { - await CalComAPIService.updateSchedule(schedule.id, { isDefault: true }); - await fetchSchedules(); - } catch (err) { - Alert.alert("Error", "Failed to set schedule as default. Please try again."); - } + const handleSetAsDefault = (schedule: Schedule) => { + setAsDefaultMutation(schedule.id, { + onError: () => { + showErrorAlert("Error", "Failed to set schedule as default. Please try again."); + }, + }); }; - const handleDuplicate = async (schedule: Schedule) => { - try { - await CalComAPIService.duplicateSchedule(schedule.id); - await fetchSchedules(); - } catch (err) { - Alert.alert("Error", "Failed to duplicate schedule. Please try again."); - } + const handleDuplicate = (schedule: Schedule) => { + duplicateScheduleMutation(schedule.id, { + onError: () => { + showErrorAlert("Error", "Failed to duplicate schedule. Please try again."); + }, + }); }; const handleDelete = (schedule: Schedule) => { @@ -180,32 +170,30 @@ export default function Availability() { { text: "Delete", style: "destructive", - onPress: async () => { - try { - await CalComAPIService.deleteSchedule(schedule.id); - await fetchSchedules(); - } catch (err) { - Alert.alert("Error", "Failed to delete schedule. Please try again."); - } + onPress: () => { + deleteScheduleMutation(schedule.id, { + onError: () => { + showErrorAlert("Error", "Failed to delete schedule. Please try again."); + }, + }); }, }, ]); } }; - const confirmDelete = async () => { + const confirmDelete = () => { if (!selectedSchedule) return; - try { - setDeleting(true); - await CalComAPIService.deleteSchedule(selectedSchedule.id); - setShowDeleteModal(false); - setSelectedSchedule(null); - await fetchSchedules(); - } catch (err) { - Alert.alert("Error", "Failed to delete schedule. Please try again."); - setDeleting(false); - } + deleteScheduleMutation(selectedSchedule.id, { + onSuccess: () => { + setShowDeleteModal(false); + setSelectedSchedule(null); + }, + onError: () => { + showErrorAlert("Error", "Failed to delete schedule. Please try again."); + }, + }); }; const handleSchedulePress = (schedule: Schedule) => { @@ -226,22 +214,20 @@ export default function Availability() { return; } + // Get user's timezone (default to America/New_York if not available) + let userTimezone = "America/New_York"; try { - setCreating(true); - - // Get user's timezone (default to America/New_York if not available) - let userTimezone = "America/New_York"; - try { - const userProfile = await CalComAPIService.getUserProfile(); - if (userProfile.timeZone) { - userTimezone = userProfile.timeZone; - } - } catch (error) { - console.log("Could not get user timezone, using default"); + const userProfile = await CalComAPIService.getUserProfile(); + if (userProfile.timeZone) { + userTimezone = userProfile.timeZone; } + } catch (error) { + console.log("Could not get user timezone, using default"); + } - // Create schedule with Monday-Friday 9 AM - 5 PM default - const newSchedule = await CalComAPIService.createSchedule({ + // Create schedule with Monday-Friday 9 AM - 5 PM default + createScheduleMutation( + { name: newScheduleName.trim(), timeZone: userTimezone, isDefault: false, @@ -252,27 +238,26 @@ export default function Availability() { endTime: "17:00", }, ], - }); + }, + { + onSuccess: (newSchedule) => { + setShowCreateModal(false); + setNewScheduleName(""); - setShowCreateModal(false); - setNewScheduleName(""); - - // Navigate to edit the newly created schedule - router.push({ - pathname: "/availability-detail", - params: { - id: newSchedule.id.toString(), + // Navigate to edit the newly created schedule + router.push({ + pathname: "/availability-detail", + params: { + id: newSchedule.id.toString(), + }, + }); }, - }); - - // Refresh the list - fetchSchedules(); - } catch (error) { - console.error("Failed to create schedule:", error); - Alert.alert("Error", "Failed to create schedule. Please try again."); - } finally { - setCreating(false); - } + onError: (error) => { + console.error("Failed to create schedule:", error); + showErrorAlert("Error", "Failed to create schedule. Please try again."); + }, + } + ); }; const renderSchedule = ({ item: schedule, index }: { item: Schedule; index: number }) => { @@ -339,8 +324,7 @@ export default function Availability() {
- - Loading availability... + ); @@ -356,7 +340,7 @@ export default function Availability() { Unable to load availability {error} - + refetch()}> Retry @@ -364,108 +348,100 @@ export default function Availability() { ); } - if (schedules.length === 0 && !loading) { - return ( - -
- - - - - New - - - - - No schedules found - - Create your availability schedule in Cal.com - - - - ); - } - - if (filteredSchedules.length === 0 && searchQuery.trim() !== "") { - return ( - -
- - - - - New - - - - - No results found - - Try searching with different keywords - - - - ); - } + // Determine what content to show + const showEmptyState = schedules.length === 0 && !loading; + const showSearchEmptyState = + filteredSchedules.length === 0 && searchQuery.trim() !== "" && !showEmptyState; + const showList = !showEmptyState && !showSearchEmptyState; return (
- - - - - New - - - - - item.id.toString()} - renderItem={renderSchedule} - contentContainerStyle={{ paddingBottom: 90 }} + + {/* Empty state - no schedules */} + {showEmptyState && ( + + } - showsVerticalScrollIndicator={false} - /> + > + + - + )} + + {/* Search bar and content for non-empty states */} + {!showEmptyState && ( + <> + + + + + New + + + + {/* Search empty state */} + {showSearchEmptyState && ( + + } + > + + + + )} + + {/* Schedules list */} + {showList && ( + + + item.id.toString()} + renderItem={renderSchedule} + contentContainerStyle={{ paddingBottom: 90 }} + refreshControl={} + showsVerticalScrollIndicator={false} + /> + + + )} + + )} {/* Create Schedule Modal */} - - Delete + + Delete @@ -645,7 +621,7 @@ export default function Availability() { {/* Icon */} - + diff --git a/companion/app/(tabs)/bookings.tsx b/companion/app/(tabs)/bookings.tsx index d4b2dfc934..5f1555ff86 100644 --- a/companion/app/(tabs)/bookings.tsx +++ b/companion/app/(tabs)/bookings.tsx @@ -1,7 +1,8 @@ import { Ionicons } from "@expo/vector-icons"; +import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import SegmentedControl from "@react-native-segmented-control/segmented-control"; import { useRouter } from "expo-router"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { View, Text, @@ -22,17 +23,121 @@ import type { NativeSyntheticEvent } from "react-native"; import { CalComAPIService, Booking, EventType } from "../../services/calcom"; import { Header } from "../../components/Header"; import { FullScreenModal } from "../../components/FullScreenModal"; +import { LoadingSpinner } from "../../components/LoadingSpinner"; +import { BookingActionsModal } from "../../components/BookingActionsModal"; +import { EmptyScreen } from "../../components/EmptyScreen"; +import { SvgImage } from "../../components/SvgImage"; +import { getAppIconUrl } from "../../utils/getAppIconUrl"; +import { useAuth } from "../../contexts/AuthContext"; +import { + useBookings, + useCancelBooking, + useConfirmBooking, + useDeclineBooking, + useRescheduleBooking, +} from "../../hooks"; +import { showErrorAlert } from "../../utils/alerts"; +import { offlineAwareRefresh } from "../../utils/network"; +import { openInAppBrowser } from "../../utils/browser"; type BookingFilter = "upcoming" | "unconfirmed" | "past" | "cancelled"; +// Helper to extract clean meeting URL from potentially wrapped URLs +const extractMeetingUrl = (location: string): string => { + // Check if it's a goo.gl redirect URL with embedded meet.google.com link + if (location.includes("goo.gl") && location.includes("meet.google.com")) { + // Extract the actual meet.google.com URL from the redirect + const meetMatch = location.match(/meet\.google\.com\/[a-z]+-[a-z]+-[a-z]+/i); + if (meetMatch) { + return `https://${meetMatch[0]}`; + } + } + return location; +}; + +// Helper to detect meeting type from location URL and get icon/label +const getMeetingInfo = ( + location?: string +): { appId: string; label: string; iconUrl: string | null; cleanUrl: string } | null => { + if (!location) return null; + + // Check if it's a URL + if (!location.match(/^https?:\/\//)) return null; + + const lowerLocation = location.toLowerCase(); + const cleanUrl = extractMeetingUrl(location); + + // Cal Video + if (lowerLocation.includes("cal.com/video") || lowerLocation.includes("cal.video")) { + return { + appId: "cal-video", + label: "Join Cal Video", + iconUrl: getAppIconUrl("daily_video", "cal-video"), + cleanUrl, + }; + } + + // Google Meet (including goo.gl redirect URLs) + if ( + lowerLocation.includes("meet.google.com") || + (lowerLocation.includes("goo.gl") && lowerLocation.includes("meet")) + ) { + return { + appId: "google-meet", + label: "Join Google Meet", + iconUrl: getAppIconUrl("google_video", "google-meet"), + cleanUrl, + }; + } + + // Zoom + if (lowerLocation.includes("zoom.us") || lowerLocation.includes("zoom.com")) { + return { + appId: "zoom", + label: "Join Zoom", + iconUrl: getAppIconUrl("zoom_video", "zoom"), + cleanUrl, + }; + } + + // Microsoft Teams + if (lowerLocation.includes("teams.microsoft.com") || lowerLocation.includes("teams.live.com")) { + return { + appId: "msteams", + label: "Join Microsoft Teams", + iconUrl: getAppIconUrl("office365_video", "msteams"), + cleanUrl, + }; + } + + // Webex + if (lowerLocation.includes("webex.com")) { + return { + appId: "webex", + label: "Join Webex", + iconUrl: getAppIconUrl("webex_video", "webex"), + cleanUrl, + }; + } + + // Jitsi + if (lowerLocation.includes("meet.jit.si") || lowerLocation.includes("jitsi")) { + return { + appId: "jitsi", + label: "Join Jitsi", + iconUrl: getAppIconUrl("jitsi_video", "jitsi"), + cleanUrl, + }; + } + + // Not a recognized conferencing app + return null; +}; + export default function Bookings() { const router = useRouter(); - const [bookings, setBookings] = useState([]); - const [filteredBookings, setFilteredBookings] = useState([]); + const { userInfo } = useAuth(); const [searchQuery, setSearchQuery] = useState(""); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); const [activeFilter, setActiveFilter] = useState("upcoming"); const [showFilterModal, setShowFilterModal] = useState(false); const [eventTypes, setEventTypes] = useState([]); @@ -41,6 +146,14 @@ export default function Bookings() { const [eventTypesLoading, setEventTypesLoading] = useState(false); const [showBookingActionsModal, setShowBookingActionsModal] = useState(false); const [selectedBooking, setSelectedBooking] = useState(null); + const [showRescheduleModal, setShowRescheduleModal] = useState(false); + const [rescheduleBooking, setRescheduleBooking] = useState(null); + const [rescheduleDate, setRescheduleDate] = useState(""); + const [rescheduleTime, setRescheduleTime] = useState(""); + const [rescheduleReason, setRescheduleReason] = useState(""); + const [showRejectModal, setShowRejectModal] = useState(false); + const [rejectBooking, setRejectBooking] = useState(null); + const [rejectReason, setRejectReason] = useState(""); const filterOptions: { key: BookingFilter; label: string }[] = [ { key: "upcoming", label: "Upcoming" }, @@ -52,176 +165,132 @@ export default function Bookings() { const filterLabels = filterOptions.map((option) => option.label); const activeIndex = filterOptions.findIndex((option) => option.key === activeFilter); + // Get filters for the active tab const getFiltersForActiveTab = () => { switch (activeFilter) { case "upcoming": - return { - status: ["upcoming"], - limit: 50, - }; + return { status: ["upcoming"], limit: 50 }; case "unconfirmed": - return { - status: ["unconfirmed"], - limit: 50, - }; + return { status: ["unconfirmed"], limit: 50 }; case "past": - return { - status: ["past"], - limit: 100, - }; + return { status: ["past"], limit: 100 }; case "cancelled": - return { - status: ["cancelled"], - limit: 100, - }; + return { status: ["cancelled"], limit: 100 }; default: - return { - status: ["upcoming"], - limit: 50, - }; + return { status: ["upcoming"], limit: 50 }; } }; - const fetchBookings = async () => { - try { - setError(null); + // Use React Query hook for fetching bookings + const { + data: rawBookings = [], + isLoading: loading, + isFetching, + error: queryError, + refetch, + } = useBookings(getFiltersForActiveTab()); - // First, test the raw bookings API call (only on first load) - if (loading) { - await CalComAPIService.testRawBookingsAPI(); - } + // Show refresh indicator when fetching + const refreshing = isFetching && !loading; - const filters = getFiltersForActiveTab(); + // Cancel booking mutation + const { mutate: cancelBookingMutation } = useCancelBooking(); - const data = await CalComAPIService.getBookings(filters); + // Confirm booking mutation + const { mutate: confirmBookingMutation, isPending: isConfirming } = useConfirmBooking(); - // Log individual bookings to see what we're getting - if (Array.isArray(data) && data.length > 0) { - } else { - } + // Decline booking mutation + const { mutate: declineBookingMutation, isPending: isDeclining } = useDeclineBooking(); - if (Array.isArray(data)) { - let filteredBookings = data; - const now = new Date(); + // Reschedule booking mutation + const { mutate: rescheduleBookingMutation, isPending: isRescheduling } = useRescheduleBooking(); - // Log all bookings before filtering + // Sort bookings based on active filter + const bookings = useMemo(() => { + if (!rawBookings || !Array.isArray(rawBookings)) return []; - // Server already filters by status correctly, so we only need to sort - // The server's logic: - // - "upcoming": endTime >= now AND status not in ["cancelled", "rejected"] - // - "unconfirmed": endTime >= now AND status = "pending" - // - "past": endTime <= now AND status not in ["cancelled", "rejected"] - // - "cancelled": status in ["cancelled", "rejected"] - switch (activeFilter) { - case "upcoming": - // Server already filtered, just sort by start time - filteredBookings = data.sort( - (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() - ); - break; - case "unconfirmed": - // Server already filtered, just sort by start time - filteredBookings = data.sort( - (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() - ); - break; - case "past": - // Server already filtered, sort by start time descending (latest first) - filteredBookings = data.sort( - (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() - ); - break; - case "cancelled": - // Server already filtered, sort by start time descending (latest first) - filteredBookings = data.sort( - (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() - ); - break; - } - - setBookings(filteredBookings); - applyFilters(filteredBookings, searchQuery, selectedEventTypeId); - } else { - setBookings([]); - setFilteredBookings([]); - } - } catch (err) { - console.error("🎯 BookingsScreen: Error fetching bookings:", err); - setError("Failed to load bookings. Please check your API key and try again."); - } finally { - setLoading(false); - setRefreshing(false); + const sorted = [...rawBookings]; + switch (activeFilter) { + case "upcoming": + case "unconfirmed": + // Sort by start time ascending + return sorted.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + case "past": + case "cancelled": + // Sort by start time descending (latest first) + return sorted.sort( + (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() + ); + default: + return sorted; } - }; + }, [rawBookings, activeFilter]); - useEffect(() => { - fetchBookings(); - }, []); + // Convert query error to string + // Don't show error UI for authentication errors (user will be redirected to login) + // Only show error UI in development mode for other errors + const isAuthError = + queryError?.message?.includes("Authentication") || + queryError?.message?.includes("sign in") || + queryError?.message?.includes("401"); + const error = queryError && !isAuthError && __DEV__ ? "Failed to load bookings." : null; + // Clear search and event type filter when status filter changes useEffect(() => { - setSearchQuery(""); // Clear search when filter changes - setSelectedEventTypeId(null); // Clear event type filter when status filter changes + setSearchQuery(""); + setSelectedEventTypeId(null); setSelectedEventTypeLabel(null); - if (!loading) { - setLoading(true); - fetchBookings(); - } }, [activeFilter]); - useEffect(() => {}, [loading, error, bookings]); + // Handle pull-to-refresh (offline-aware) + const onRefresh = () => offlineAwareRefresh(refetch); - const onRefresh = () => { - setRefreshing(true); - fetchBookings(); - }; - - const applyFilters = ( - bookingsToFilter: Booking[], - searchText: string, - eventTypeId: number | null - ) => { - let filtered = bookingsToFilter; + // Apply local filters (search and event type) using useMemo + const filteredBookings = useMemo(() => { + let filtered = bookings; // Apply event type filter - if (eventTypeId !== null) { - filtered = filtered.filter((booking) => booking.eventTypeId === eventTypeId); + if (selectedEventTypeId !== null) { + filtered = filtered.filter((booking) => booking.eventTypeId === selectedEventTypeId); } // Apply search filter - if (searchText.trim() !== "") { + if (searchQuery.trim() !== "") { + const searchLower = searchQuery.toLowerCase(); filtered = filtered.filter( (booking) => // Search in booking title - booking.title?.toLowerCase().includes(searchText.toLowerCase()) || + booking.title?.toLowerCase().includes(searchLower) || // Search in booking description - booking.description?.toLowerCase().includes(searchText.toLowerCase()) || + booking.description?.toLowerCase().includes(searchLower) || // Search in event type title - booking.eventType?.title?.toLowerCase().includes(searchText.toLowerCase()) || + booking.eventType?.title?.toLowerCase().includes(searchLower) || // Search in attendee names (booking.attendees && booking.attendees.some((attendee) => - attendee.name?.toLowerCase().includes(searchText.toLowerCase()) + attendee.name?.toLowerCase().includes(searchLower) )) || // Search in attendee emails (booking.attendees && booking.attendees.some((attendee) => - attendee.email?.toLowerCase().includes(searchText.toLowerCase()) + attendee.email?.toLowerCase().includes(searchLower) )) || // Search in location - booking.location?.toLowerCase().includes(searchText.toLowerCase()) || + booking.location?.toLowerCase().includes(searchLower) || // Search in user name - booking.user?.name?.toLowerCase().includes(searchText.toLowerCase()) || + booking.user?.name?.toLowerCase().includes(searchLower) || // Search in user email - booking.user?.email?.toLowerCase().includes(searchText.toLowerCase()) + booking.user?.email?.toLowerCase().includes(searchLower) ); } - setFilteredBookings(filtered); - }; + return filtered; + }, [bookings, searchQuery, selectedEventTypeId]); const handleSearch = (query: string) => { setSearchQuery(query); - applyFilters(bookings, query, selectedEventTypeId); }; const fetchEventTypes = async () => { @@ -231,7 +300,7 @@ export default function Bookings() { setEventTypes(types); } catch (err) { console.error("Error fetching event types:", err); - setError("Failed to load event types"); + // Error is logged but not displayed to user for event type filter } finally { setEventTypesLoading(false); } @@ -247,7 +316,6 @@ export default function Bookings() { const clearEventTypeFilter = () => { setSelectedEventTypeId(null); setSelectedEventTypeLabel(null); - applyFilters(bookings, searchQuery, null); }; const handleEventTypeSelect = (eventTypeId: number | null, label?: string | null) => { @@ -256,7 +324,6 @@ export default function Bookings() { } else { setSelectedEventTypeId(eventTypeId); setSelectedEventTypeLabel(label || null); - applyFilters(bookings, searchQuery, eventTypeId); } setShowFilterModal(false); }; @@ -277,49 +344,66 @@ export default function Bookings() { switch (activeFilter) { case "upcoming": return { - icon: "calendar-clear-outline", + icon: "calendar-outline" as const, title: "No upcoming bookings", - text: "Your upcoming appointments will appear here", + text: "As soon as someone books a time with you it will show up here.", }; case "unconfirmed": return { - icon: "hourglass-outline", + icon: "calendar-outline" as const, title: "No unconfirmed bookings", - text: "Bookings awaiting confirmation will appear here", + text: "Your unconfirmed bookings will show up here.", }; case "past": return { - icon: "archive-outline", + icon: "calendar-outline" as const, title: "No past bookings", - text: "Your completed appointments will appear here", + text: "Your past bookings will show up here.", }; case "cancelled": return { - icon: "close-circle-outline", + icon: "calendar-outline" as const, title: "No cancelled bookings", - text: "Cancelled or rejected bookings will appear here", + text: "Your canceled bookings will show up here.", }; default: return { - icon: "calendar-clear-outline", + icon: "calendar-outline" as const, title: "No bookings found", - text: "Your bookings will appear here", + text: "Your bookings will appear here.", }; } }; + const supportsLiquidGlass = isLiquidGlassAvailable(); + const renderSegmentedControl = () => { + const segmentedControlContent = ( + + ); + return ( <> - - - + {supportsLiquidGlass ? ( + + {segmentedControlContent} + + ) : ( + + {segmentedControlContent} + + )} handleRescheduleBooking(booking), destructive: false, }, - { - title: "Report booking", - onPress: () => handleReportBooking(booking), - destructive: false, - }, { title: "Cancel event", onPress: () => handleCancelEvent(booking), @@ -470,27 +549,15 @@ export default function Bookings() { destructive: false, }, { - title: "Reject booking", + title: "Decline booking", onPress: () => handleRejectBooking(booking), destructive: true, }, ]; case "past": - return [ - { - title: "Report booking", - onPress: () => handleReportBooking(booking), - destructive: false, - }, - ]; + return []; case "cancelled": - return [ - { - title: "Report booking", - onPress: () => handleReportBooking(booking), - destructive: false, - }, - ]; + return []; default: return []; } @@ -508,12 +575,8 @@ export default function Bookings() { try { // Check if location is a URL (starts with http:// or https://) if (booking.location.match(/^https?:\/\//)) { - const supported = await Linking.canOpenURL(booking.location); - if (supported) { - await Linking.openURL(booking.location); - } else { - Alert.alert("Error", "Cannot open this URL on your device."); - } + // Open web URLs in in-app browser + await openInAppBrowser(booking.location, "meeting link"); } else { // If it's not a URL, try to open it as a location in maps const mapsUrl = @@ -525,28 +588,76 @@ export default function Bookings() { if (supported) { await Linking.openURL(mapsUrl); } else { - // Fallback to Google Maps web + // Fallback to Google Maps in in-app browser const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent( booking.location )}`; - await Linking.openURL(googleMapsUrl); + await openInAppBrowser(googleMapsUrl, "Google Maps"); } } } catch (error) { - Alert.alert("Error", "Failed to open location. Please try again."); + showErrorAlert("Error", "Failed to open location. Please try again."); } }; const handleRescheduleBooking = (booking: Booking) => { - Alert.alert("Reschedule Booking", "Reschedule functionality coming soon"); + // Pre-fill with the current booking date/time + const currentDate = new Date(booking.startTime); + const dateStr = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD + const timeStr = currentDate.toTimeString().slice(0, 5); // HH:MM + + setRescheduleBooking(booking); + setRescheduleDate(dateStr); + setRescheduleTime(timeStr); + setRescheduleReason(""); + setShowRescheduleModal(true); }; - const handleRequestReschedule = (booking: Booking) => { - Alert.alert("Request Reschedule", "Request reschedule functionality coming soon"); - }; + const handleSubmitReschedule = () => { + if (!rescheduleBooking || !rescheduleDate || !rescheduleTime) { + showErrorAlert("Error", "Please enter both date and time"); + return; + } - const handleReportBooking = (booking: Booking) => { - Alert.alert("Report Booking", "Report functionality coming soon"); + // Parse the date and time + const dateTimeStr = `${rescheduleDate}T${rescheduleTime}:00`; + const newDateTime = new Date(dateTimeStr); + + // Validate the date + if (isNaN(newDateTime.getTime())) { + showErrorAlert( + "Error", + "Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time." + ); + return; + } + + // Check if the new time is in the future + if (newDateTime <= new Date()) { + showErrorAlert("Error", "Please select a future date and time"); + return; + } + + // Convert to UTC ISO string + const startUtc = newDateTime.toISOString(); + + rescheduleBookingMutation( + { + uid: rescheduleBooking.uid, + start: startUtc, + reschedulingReason: rescheduleReason || undefined, + }, + { + onSuccess: () => { + setShowRescheduleModal(false); + setRescheduleBooking(null); + Alert.alert("Success", "Booking rescheduled successfully"); + }, + onError: (error) => { + showErrorAlert("Error", error.message || "Failed to reschedule booking"); + }, + } + ); }; const handleCancelEvent = (booking: Booking) => { @@ -565,27 +676,20 @@ export default function Bookings() { { text: "Cancel Event", style: "destructive", - onPress: async (reason) => { - try { - const cancellationReason = reason?.trim() || "Event cancelled by host"; - await CalComAPIService.cancelBooking(booking.uid, cancellationReason); - - // Remove the cancelled booking from local state or refresh the list - if (activeFilter === "upcoming") { - // For upcoming bookings, remove from list since it's now cancelled - const updatedBookings = bookings.filter((b) => b.uid !== booking.uid); - setBookings(updatedBookings); - setFilteredBookings(updatedBookings); - } else { - // For other filters, refresh to get updated data - await fetchBookings(); + onPress: (reason) => { + const cancellationReason = reason?.trim() || "Event cancelled by host"; + cancelBookingMutation( + { uid: booking.uid, reason: cancellationReason }, + { + onSuccess: () => { + Alert.alert("Success", "Event cancelled successfully"); + }, + onError: (error) => { + console.error("Failed to cancel booking:", error); + showErrorAlert("Error", "Failed to cancel event. Please try again."); + }, } - - Alert.alert("Success", "Event cancelled successfully"); - } catch (error) { - console.error("Failed to cancel booking:", error); - Alert.alert("Error", "Failed to cancel event. Please try again."); - } + ); }, }, ], @@ -599,17 +703,61 @@ export default function Bookings() { }; const handleConfirmBooking = (booking: Booking) => { - Alert.alert("Confirm Booking", "Confirm functionality coming soon"); + Alert.alert("Confirm Booking", `Are you sure you want to confirm "${booking.title}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Confirm", + onPress: () => { + confirmBookingMutation( + { uid: booking.uid }, + { + onSuccess: () => { + Alert.alert("Success", "Booking confirmed successfully"); + }, + onError: (error) => { + showErrorAlert("Error", error.message || "Failed to confirm booking"); + }, + } + ); + }, + }, + ]); }; const handleRejectBooking = (booking: Booking) => { - Alert.alert("Reject Booking", `Are you sure you want to reject "${booking.title}"?`, [ + Alert.alert("Decline Booking", `Are you sure you want to decline "${booking.title}"?`, [ { text: "Cancel", style: "cancel" }, { - text: "Reject", + text: "Decline", style: "destructive", onPress: () => { - Alert.alert("Reject Booking", "Reject functionality coming soon"); + // Show optional reason input + Alert.prompt( + "Decline Reason", + "Optionally provide a reason for declining (press OK to skip)", + [ + { text: "Cancel", style: "cancel" }, + { + text: "OK", + onPress: (reason?: string) => { + declineBookingMutation( + { uid: booking.uid, reason: reason || undefined }, + { + onSuccess: () => { + Alert.alert("Success", "Booking declined successfully"); + }, + onError: (error) => { + showErrorAlert("Error", error.message || "Failed to decline booking"); + }, + } + ); + }, + }, + ], + "plain-text", + "", + "default" + ); }, }, ]); @@ -835,38 +983,145 @@ export default function Bookings() { item.user || (item.attendees && item.attendees.length > 0)) && ( - {/* Host */} - {(item.hosts && item.hosts.length > 0) || item.user ? ( - <> - {item.hosts && item.hosts.length > 0 - ? item.hosts[0].name || item.hosts[0].email - : item.user?.name || item.user?.email} - - ) : null} + {(() => { + // Check if current user is the host + const currentUserEmail = userInfo?.email?.toLowerCase(); + const hostEmail = + item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase(); + const isCurrentUserHost = + currentUserEmail && hostEmail && currentUserEmail === hostEmail; - {/* Separator */} - {((item.hosts && item.hosts.length > 0) || item.user) && - item.attendees && - item.attendees.length > 0 && and } + // Get host display name + const hostName = isCurrentUserHost + ? "You" + : item.hosts?.[0]?.name || + item.hosts?.[0]?.email || + item.user?.name || + item.user?.email; - {/* Attendees */} - {item.attendees && item.attendees.length > 0 && ( - <> - {item.attendees.length === 1 - ? item.attendees[0].name || item.attendees[0].email - : item.attendees - .slice(0, 2) - .map((att) => att.name || att.email) - .join(", ") + - (item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "")} - - )} + // Get attendees display + const attendeesDisplay = + item.attendees && item.attendees.length > 0 + ? item.attendees.length === 1 + ? item.attendees[0].name || item.attendees[0].email + : item.attendees + .slice(0, 2) + .map((att) => att.name || att.email) + .join(", ") + + (item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "") + : null; + + // Combine host and attendees + if (hostName && attendeesDisplay) { + return `${hostName} and ${attendeesDisplay}`; + } else if (hostName) { + return hostName; + } else if (attendeesDisplay) { + return attendeesDisplay; + } + return null; + })()} )} + + {/* Meeting Link - only for video conferencing apps */} + {(() => { + const meetingInfo = getMeetingInfo(item.location); + if (!meetingInfo) return null; + + return ( + + { + e.stopPropagation(); + try { + await Linking.openURL(meetingInfo.cleanUrl); + } catch { + showErrorAlert("Error", "Failed to open meeting link. Please try again."); + } + }} + > + {meetingInfo.iconUrl ? ( + + ) : ( + + )} + {meetingInfo.label} + + + ); + })()} - {/* Three dots button - below content, aligned to the right */} - + {/* Action buttons row */} + + {/* Confirm and Reject buttons for unconfirmed bookings */} + {isPending && ( + <> + { + e.stopPropagation(); + confirmBookingMutation( + { uid: item.uid }, + { + onSuccess: () => { + Alert.alert("Success", "Booking confirmed successfully"); + }, + onError: (error) => { + showErrorAlert("Error", "Failed to confirm booking. Please try again."); + }, + } + ); + }} + > + + Confirm + + { + e.stopPropagation(); + setRejectBooking(item); + setRejectReason(""); + setShowRejectModal(true); + }} + > + + Reject + + + )} + + {/* Three dots button */} {renderSegmentedControl()} - - Loading {activeFilter} bookings... + ); @@ -918,7 +1172,7 @@ export default function Bookings() { Unable to load bookings {error} - + refetch()}> Retry @@ -926,59 +1180,75 @@ export default function Bookings() { ); } - if (bookings.length === 0 && !loading) { - const emptyState = getEmptyStateContent(); - return ( - -
- {renderSegmentedControl()} - - - - {emptyState.title} - - {emptyState.text} - - - ); - } - - if (filteredBookings.length === 0 && searchQuery.trim() !== "" && !loading) { - return ( - -
- {renderSegmentedControl()} - - - No results found - - Try searching with different keywords - - - - ); - } + // Determine what content to show + const showEmptyState = bookings.length === 0 && !loading; + const showSearchEmptyState = + filteredBookings.length === 0 && searchQuery.trim() !== "" && !loading && !showEmptyState; + const showList = !showEmptyState && !showSearchEmptyState && !loading; + const emptyState = getEmptyStateContent(); return (
{renderSegmentedControl()} - - - item.key} - renderItem={renderListItem} - contentContainerStyle={{ paddingBottom: 90 }} + + {/* Empty state - no bookings */} + {showEmptyState && ( + + } - showsVerticalScrollIndicator={false} - /> + > + + - + )} + + {/* Search empty state */} + {showSearchEmptyState && ( + + } + > + + + + )} + + {/* Bookings list */} + {showList && ( + + + item.key} + renderItem={renderListItem} + contentContainerStyle={{ paddingBottom: 90 }} + refreshControl={} + showsVerticalScrollIndicator={false} + /> + + + )} {/* Filter Modal */} {/* Booking Actions Modal */} - setShowBookingActionsModal(false)} + onClose={() => setShowBookingActionsModal(false)} + booking={selectedBooking} + hasLocationUrl={!!selectedBooking?.location} + isUpcoming={ + selectedBooking + ? new Date(selectedBooking.endTime || selectedBooking.end || "") >= new Date() && + selectedBooking.status?.toUpperCase() !== "PENDING" + : false + } + isPast={ + selectedBooking + ? new Date(selectedBooking.endTime || selectedBooking.end || "") < new Date() + : false + } + isCancelled={selectedBooking?.status?.toUpperCase() === "CANCELLED"} + isUnconfirmed={selectedBooking?.status?.toUpperCase() === "PENDING"} + onReschedule={() => { + if (selectedBooking) handleRescheduleBooking(selectedBooking); + }} + onEditLocation={() => { + Alert.alert("Edit Location", "Edit location functionality coming soon"); + }} + onAddGuests={() => { + Alert.alert("Add Guests", "Add guests functionality coming soon"); + }} + onViewRecordings={() => { + Alert.alert("View Recordings", "View recordings functionality coming soon"); + }} + onMeetingSessionDetails={() => { + Alert.alert( + "Meeting Session Details", + "Meeting session details functionality coming soon" + ); + }} + onMarkNoShow={() => { + Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon"); + }} + onReportBooking={() => { + Alert.alert("Report Booking", "Report booking functionality coming soon"); + }} + onCancelBooking={() => { + if (selectedBooking) handleCancelEvent(selectedBooking); + }} + /> + + {/* Reschedule Modal */} + { + setShowRescheduleModal(false); + setRescheduleBooking(null); + }} > - setShowBookingActionsModal(false)} - > - e.stopPropagation()} - > - {/* Header */} - - - Booking Actions + + {rescheduleBooking && ( + <> + + Reschedule "{rescheduleBooking.title}" - - {/* Actions List */} - - {/* View Booking */} - { - setShowBookingActionsModal(false); - if (selectedBooking) { - handleBookingPress(selectedBooking); - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - View Booking - - - {/* Separator */} - - - {/* Edit event label */} - - Edit event + {/* Date Input */} + + + New Date (YYYY-MM-DD) + + - {/* Request Reschedule */} - {activeFilter === "upcoming" && ( - { - setShowBookingActionsModal(false); - if (selectedBooking) { - handleRescheduleBooking(selectedBooking); - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Send Reschedule Request - - )} + {/* Time Input */} + + + New Time (HH:MM, 24-hour format) + + + - {/* Edit Location */} - {activeFilter === "upcoming" && ( - { - setShowBookingActionsModal(false); - Alert.alert("Edit Location", "Edit location functionality coming soon"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Edit Location - - )} + {/* Reason Input */} + + Reason (optional) + + - {/* Add Guests */} - {activeFilter === "upcoming" && ( - { - setShowBookingActionsModal(false); - Alert.alert("Add Guests", "Add guests functionality coming soon"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Add Guests - - )} - - {/* Open Location */} - {selectedBooking?.location && ( - <> - - { - setShowBookingActionsModal(false); - if (selectedBooking) { - handleOpenLocation(selectedBooking); - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Open Location - - - )} - - {/* Separator */} - {(activeFilter === "past" || activeFilter === "upcoming") && ( - <> - - - {/* After event label */} - {activeFilter === "past" && ( - - After event - - )} - - {/* Mark as No-Show */} - {activeFilter === "past" && ( - { - setShowBookingActionsModal(false); - Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Mark as No-Show - - )} - - )} - - {/* Confirm booking (for unconfirmed) */} - {activeFilter === "unconfirmed" && ( - <> - - { - setShowBookingActionsModal(false); - if (selectedBooking) { - handleConfirmBooking(selectedBooking); - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Confirm booking - - - )} - - {/* Separator */} - - - {/* Report Booking */} + {/* Submit Button */} { - setShowBookingActionsModal(false); - if (selectedBooking) { - handleReportBooking(selectedBooking); - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + className={`rounded-lg p-4 ${isRescheduling ? "bg-gray-400" : "bg-black"}`} + onPress={handleSubmitReschedule} + disabled={isRescheduling} > - - Report Booking + {isRescheduling ? ( + + ) : ( + + Reschedule Booking + + )} - {/* Separator */} - - - {/* Cancel/Reject Booking */} - {(activeFilter === "upcoming" || activeFilter === "unconfirmed") && ( - { - setShowBookingActionsModal(false); - if (selectedBooking) { - if (activeFilter === "unconfirmed") { - handleRejectBooking(selectedBooking); - } else { - handleCancelEvent(selectedBooking); - } - } - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - - {activeFilter === "unconfirmed" ? "Reject booking" : "Cancel event"} - - - )} - - - {/* Cancel button */} - + {/* Cancel Button */} setShowBookingActionsModal(false)} + className="mt-3 rounded-lg bg-gray-100 p-4" + onPress={() => { + setShowRescheduleModal(false); + setRescheduleBooking(null); + }} > Cancel + + )} + + + + {/* Reject Booking Modal */} + { + setShowRejectModal(false); + setRejectBooking(null); + setRejectReason(""); + }} + > + { + setShowRejectModal(false); + setRejectBooking(null); + setRejectReason(""); + }} + > + e.stopPropagation()} + > + + {/* Title */} + + Reject the booking request? + + + {/* Description */} + + Are you sure you want to reject the booking? We'll let the person who tried to book + know. You can provide a reason below. + + + {/* Reason Input */} + + + Reason for rejecting (Optional) + + + + + {/* Separator */} + + + {/* Buttons Row */} + + {/* Close Button */} + { + setShowRejectModal(false); + setRejectBooking(null); + setRejectReason(""); + }} + > + Close + + + {/* Reject Button */} + { + if (rejectBooking) { + declineBookingMutation( + { uid: rejectBooking.uid, reason: rejectReason || undefined }, + { + onSuccess: () => { + setShowRejectModal(false); + setRejectBooking(null); + setRejectReason(""); + Alert.alert("Success", "Booking rejected successfully"); + }, + onError: (error) => { + showErrorAlert("Error", "Failed to reject booking. Please try again."); + }, + } + ); + } + }} + disabled={isDeclining} + style={{ opacity: isDeclining ? 0.5 : 1 }} + > + Reject the booking + + diff --git a/companion/app/(tabs)/event-types.tsx b/companion/app/(tabs)/event-types.tsx index 7b195fd909..51f0d881c3 100644 --- a/companion/app/(tabs)/event-types.tsx +++ b/companion/app/(tabs)/event-types.tsx @@ -1,53 +1,81 @@ import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; import { View, Text, FlatList, ScrollView, - ActivityIndicator, TouchableOpacity, RefreshControl, TextInput, ActionSheetIOS, Share, Alert, - Clipboard, Platform, Modal, KeyboardAvoidingView, - Linking, } from "react-native"; +import * as Clipboard from "expo-clipboard"; import Svg, { Path } from "react-native-svg"; import { CalComAPIService, EventType } from "../../services/calcom"; import { Header } from "../../components/Header"; import { Tooltip } from "../../components/Tooltip"; import { FullScreenModal } from "../../components/FullScreenModal"; +import { LoadingSpinner } from "../../components/LoadingSpinner"; +import { EmptyScreen } from "../../components/EmptyScreen"; import { slugify } from "../../utils/slugify"; +import { showErrorAlert } from "../../utils/alerts"; +import { offlineAwareRefresh } from "../../utils/network"; +import { openInAppBrowser } from "../../utils/browser"; +import { formatDuration } from "../../components/event-type-detail/utils"; +import { + useEventTypes, + useCreateEventType, + useDeleteEventType, + useDuplicateEventType, + useUsername, +} from "../../hooks"; export default function EventTypes() { - console.log("EventTypes component rendering"); const router = useRouter(); - const [eventTypes, setEventTypes] = useState([]); - const [filteredEventTypes, setFilteredEventTypes] = useState([]); const [searchQuery, setSearchQuery] = useState(""); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const isMountedRef = useRef(true); // Modal state for creating new event type const [showCreateModal, setShowCreateModal] = useState(false); - const [creating, setCreating] = useState(false); const [newEventTitle, setNewEventTitle] = useState(""); const [newEventSlug, setNewEventSlug] = useState(""); const [newEventDescription, setNewEventDescription] = useState(""); const [newEventDuration, setNewEventDuration] = useState("15"); - const [username, setUsername] = useState(""); const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false); + // Use React Query hooks + const { + data: eventTypes = [], + isLoading: loading, + isFetching, + error: queryError, + refetch, + } = useEventTypes(); + + // Show refresh indicator when fetching + const refreshing = isFetching && !loading; + + const { data: username = "" } = useUsername(); + const { mutate: createEventTypeMutation, isPending: creating } = useCreateEventType(); + const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType(); + const { mutate: duplicateEventTypeMutation } = useDuplicateEventType(); + + // Convert query error to string + // Don't show error UI for authentication errors (user will be redirected to login) + // Only show error UI in development mode for other errors + const isAuthError = + queryError?.message?.includes("Authentication") || + queryError?.message?.includes("sign in") || + queryError?.message?.includes("401"); + const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null; + // Modal state for web platform action sheet const [showActionModal, setShowActionModal] = useState(false); const [selectedEventType, setSelectedEventType] = useState(null); @@ -58,7 +86,6 @@ export default function EventTypes() { // Modal state for delete confirmation const [showDeleteModal, setShowDeleteModal] = useState(false); const [eventTypeToDelete, setEventTypeToDelete] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); // Toast state for web platform const [showToast, setShowToast] = useState(false); @@ -78,89 +105,25 @@ export default function EventTypes() { }, 2000); }; - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); + // Handle pull-to-refresh + // Handle pull-to-refresh (offline-aware) + const onRefresh = () => offlineAwareRefresh(refetch); - // Fetch username on mount - useEffect(() => { - const fetchUsername = async () => { - try { - const fetchedUsername = await CalComAPIService.getUsername(); - setUsername(fetchedUsername); - } catch (error) { - console.error("Failed to fetch username:", error); - // Keep default username if fetch fails - } - }; - fetchUsername(); - }, []); - - const fetchEventTypes = async () => { - try { - setError(null); - - const data = await CalComAPIService.getEventTypes(); - - if (isMountedRef.current) { - if (Array.isArray(data)) { - setEventTypes(data); - setFilteredEventTypes(data); - } else { - setEventTypes([]); - setFilteredEventTypes([]); - } - } - } catch (err) { - console.error("🎯 EventTypesScreen: Error fetching event types:", err); - if (isMountedRef.current) { - setError("Failed to load event types. Please check your API key and try again."); - } - } finally { - if (isMountedRef.current) { - setLoading(false); - setRefreshing(false); - } + // Filter event types based on search query + const filteredEventTypes = useMemo(() => { + if (searchQuery.trim() === "") { + return eventTypes; } - }; - - useEffect(() => { - fetchEventTypes(); - }, []); - - const onRefresh = () => { - setRefreshing(true); - fetchEventTypes(); - }; + const searchLower = searchQuery.toLowerCase(); + return eventTypes.filter( + (eventType) => + eventType.title.toLowerCase().includes(searchLower) || + (eventType.description && eventType.description.toLowerCase().includes(searchLower)) + ); + }, [eventTypes, searchQuery]); const handleSearch = (query: string) => { setSearchQuery(query); - if (query.trim() === "") { - setFilteredEventTypes(eventTypes); - } else { - const filtered = eventTypes.filter( - (eventType) => - eventType.title.toLowerCase().includes(query.toLowerCase()) || - (eventType.description && - eventType.description.toLowerCase().includes(query.toLowerCase())) - ); - setFilteredEventTypes(filtered); - } - }; - - const formatDuration = (minutes: number | undefined) => { - if (!minutes || minutes <= 0) { - return "0m"; - } - if (minutes < 60) { - return `${minutes}m`; - } - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; }; const getDuration = (eventType: EventType): number => { @@ -259,7 +222,7 @@ export default function EventTypes() { const handleCopyLink = async (eventType: EventType) => { try { const link = await CalComAPIService.buildEventTypeLink(eventType.slug); - Clipboard.setString(link); + await Clipboard.setStringAsync(link); if (Platform.OS === "web") { showToastMessage("Link copied!", eventType.id); @@ -270,7 +233,7 @@ export default function EventTypes() { if (Platform.OS === "web") { showToastMessage("Failed to copy link"); } else { - Alert.alert("Error", "Failed to copy link. Please try again."); + showErrorAlert("Error", "Failed to copy link. Please try again."); } } }; @@ -283,7 +246,7 @@ export default function EventTypes() { url: link, }); } catch (error) { - Alert.alert("Error", "Failed to share link. Please try again."); + showErrorAlert("Error", "Failed to share link. Please try again."); } }; @@ -308,20 +271,11 @@ export default function EventTypes() { setShowDeleteModal(true); }; - const confirmDelete = async () => { + const confirmDelete = () => { if (!eventTypeToDelete) return; - setIsDeleting(true); - try { - await CalComAPIService.deleteEventType(eventTypeToDelete.id); - - // Only update state if component is still mounted - if (isMountedRef.current) { - // Remove the deleted event type from local state - const updatedEventTypes = eventTypes.filter((et) => et.id !== eventTypeToDelete.id); - setEventTypes(updatedEventTypes); - setFilteredEventTypes(updatedEventTypes); - + deleteEventTypeMutation(eventTypeToDelete.id, { + onSuccess: () => { // Close modal and reset state setShowDeleteModal(false); setEventTypeToDelete(null); @@ -331,76 +285,57 @@ export default function EventTypes() { } else { Alert.alert("Success", "Event type deleted successfully"); } - } - } catch (error) { - console.error("Failed to delete event type:", error); - if (isMountedRef.current) { + }, + onError: (error) => { + console.error("Failed to delete event type:", error); if (Platform.OS === "web") { showToastMessage("Failed to delete event type"); } else { - Alert.alert("Error", "Failed to delete event type. Please try again."); + showErrorAlert("Error", "Failed to delete event type. Please try again."); } - } - } finally { - setIsDeleting(false); - } + }, + }); }; - const handleDuplicate = async (eventType: EventType) => { - try { - // Generate a new title and slug for the duplicate - const newTitle = `${eventType.title} (copy)`; - let newSlug = `${eventType.slug}-copy`; + const handleDuplicate = (eventType: EventType) => { + duplicateEventTypeMutation( + { eventType, existingEventTypes: eventTypes }, + { + onSuccess: (duplicatedEventType) => { + if (Platform.OS === "web") { + showToastMessage("Event type duplicated successfully"); + } else { + Alert.alert("Success", "Event type duplicated successfully"); + } - // Check if slug already exists and append a number if needed - let counter = 1; - while (eventTypes.some((et) => et.slug === newSlug)) { - newSlug = `${eventType.slug}-copy-${counter}`; - counter++; - } + const duration = getDuration(eventType); - const duration = getDuration(eventType); - - // Create the duplicate event type - const duplicatedEventType = await CalComAPIService.createEventType({ - title: newTitle, - slug: newSlug, - lengthInMinutes: duration, - description: eventType.description || undefined, - }); - - // Refresh the list - await fetchEventTypes(); - - if (Platform.OS === "web") { - showToastMessage("Event type duplicated successfully"); - } else { - Alert.alert("Success", "Event type duplicated successfully"); - } - - // Navigate to edit the newly created duplicate - router.push({ - pathname: "/event-type-detail", - params: { - id: duplicatedEventType.id.toString(), - title: duplicatedEventType.title, - description: duplicatedEventType.description || "", - duration: ( - duplicatedEventType.lengthInMinutes || - duplicatedEventType.length || - duration - ).toString(), - slug: duplicatedEventType.slug || "", + // Navigate to edit the newly created duplicate + router.push({ + pathname: "/event-type-detail", + params: { + id: duplicatedEventType.id.toString(), + title: duplicatedEventType.title, + description: duplicatedEventType.description || "", + duration: ( + duplicatedEventType.lengthInMinutes || + duplicatedEventType.length || + duration + ).toString(), + slug: duplicatedEventType.slug || "", + }, + }); + }, + onError: (error) => { + console.error("Failed to duplicate event type:", error); + if (Platform.OS === "web") { + showToastMessage("Failed to duplicate event type"); + } else { + showErrorAlert("Error", "Failed to duplicate event type. Please try again."); + } }, - }); - } catch (error) { - console.error("Failed to duplicate event type:", error); - if (Platform.OS === "web") { - showToastMessage("Failed to duplicate event type"); - } else { - Alert.alert("Error", "Failed to duplicate event type. Please try again."); } - } + ); }; const handlePreview = async (eventType: EventType) => { @@ -410,15 +345,15 @@ export default function EventTypes() { if (Platform.OS === "web") { window.open(link, "_blank"); } else { - // For mobile, use Linking - await Linking.openURL(link); + // For mobile, use in-app browser + await openInAppBrowser(link, "event type preview"); } } catch (error) { console.error("Failed to open preview:", error); if (Platform.OS === "web") { showToastMessage("Failed to open preview"); } else { - Alert.alert("Error", "Failed to open preview. Please try again."); + showErrorAlert("Error", "Failed to open preview. Please try again."); } } }; @@ -440,7 +375,7 @@ export default function EventTypes() { setIsSlugManuallyEdited(false); }; - const handleCreateEventType = async () => { + const handleCreateEventType = () => { if (!newEventTitle.trim()) { Alert.alert("Error", "Please enter a title for your event type"); return; @@ -457,39 +392,36 @@ export default function EventTypes() { return; } - setCreating(true); - try { - // Create the event type with the form data - const newEventType = await CalComAPIService.createEventType({ + createEventTypeMutation( + { title: newEventTitle.trim(), slug: newEventSlug.trim(), lengthInMinutes: duration, description: newEventDescription.trim() || undefined, - }); + }, + { + onSuccess: (newEventType) => { + // Close modal and reset form + handleCloseCreateModal(); - // Close modal and reset form - handleCloseCreateModal(); - - // Refresh the list - await fetchEventTypes(); - - // Navigate to edit the newly created event type - router.push({ - pathname: "/event-type-detail", - params: { - id: newEventType.id.toString(), - title: newEventType.title, - description: newEventType.description || "", - duration: (newEventType.lengthInMinutes || newEventType.length || 15).toString(), - slug: newEventType.slug || "", + // Navigate to edit the newly created event type + router.push({ + pathname: "/event-type-detail", + params: { + id: newEventType.id.toString(), + title: newEventType.title, + description: newEventType.description || "", + duration: (newEventType.lengthInMinutes || newEventType.length || 15).toString(), + slug: newEventType.slug || "", + }, + }); }, - }); - } catch (error) { - console.error("Failed to create event type:", error); - Alert.alert("Error", "Failed to create event type. Please try again."); - } finally { - setCreating(false); - } + onError: (error) => { + console.error("Failed to create event type:", error); + showErrorAlert("Error", "Failed to create event type. Please try again."); + }, + } + ); }; const renderEventType = ({ item, index }: { item: EventType; index: number }) => { @@ -590,8 +522,7 @@ export default function EventTypes() {
- - Loading event types... + ); @@ -607,7 +538,7 @@ export default function EventTypes() { Unable to load event types {error} - + refetch()}> Retry @@ -619,24 +550,14 @@ export default function EventTypes() { return (
- - - - - No event types found - - Create your first event type in Cal.com - + ); @@ -646,7 +567,7 @@ export default function EventTypes() { return (
- + + + + New + - - No results found - - Try searching with different keywords - + ); @@ -914,8 +842,8 @@ export default function EventTypes() { if (eventType) handleDelete(eventType); }} > - - Delete + + Delete @@ -1007,7 +935,7 @@ export default function EventTypes() { {/* Danger icon */} - + {/* Title and description */} diff --git a/companion/app/(tabs)/more.tsx b/companion/app/(tabs)/more.tsx index d33ca5250b..473ac7c6f1 100644 --- a/companion/app/(tabs)/more.tsx +++ b/companion/app/(tabs)/more.tsx @@ -1,9 +1,11 @@ import React from "react"; -import { View, Text, TouchableOpacity, ScrollView, Linking, Alert } from "react-native"; +import { View, Text, TouchableOpacity, ScrollView, Alert } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; import { Header } from "../../components/Header"; -import { LogoutButton } from "../../components/LogoutButton"; +import { useAuth } from "../../contexts/AuthContext"; +import { showErrorAlert } from "../../utils/alerts"; +import { openInAppBrowser } from "../../utils/browser"; interface MoreMenuItem { name: string; @@ -15,19 +17,24 @@ interface MoreMenuItem { export default function More() { const router = useRouter(); + const { logout } = useAuth(); - const openExternalLink = async (url: string, fallbackMessage: string) => { - try { - const supported = await Linking.canOpenURL(url); - if (supported) { - await Linking.openURL(url); - } else { - Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`); - } - } catch (error) { - console.error(`Failed to open ${url}:`, error); - Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`); - } + const handleSignOut = () => { + Alert.alert("Sign Out", "Are you sure you want to sign out?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Sign Out", + style: "destructive", + onPress: async () => { + try { + await logout(); + } catch (error) { + console.error("Logout error:", error); + showErrorAlert("Error", "Failed to sign out. Please try again."); + } + }, + }, + ]); }; const menuItems: MoreMenuItem[] = [ @@ -36,37 +43,37 @@ export default function More() { icon: "person-outline", isExternal: true, onPress: () => - openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page"), + openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"), }, { name: "Apps", icon: "grid-outline", isExternal: true, - onPress: () => openExternalLink("https://app.cal.com/apps", "Apps page"), + onPress: () => openInAppBrowser("https://app.cal.com/apps", "Apps page"), }, { name: "Routing", icon: "git-branch-outline", isExternal: true, - onPress: () => openExternalLink("https://app.cal.com/routing", "Routing page"), + onPress: () => openInAppBrowser("https://app.cal.com/routing", "Routing page"), }, { name: "Workflows", icon: "flash-outline", isExternal: true, - onPress: () => openExternalLink("https://app.cal.com/workflows", "Workflows page"), + onPress: () => openInAppBrowser("https://app.cal.com/workflows", "Workflows page"), }, { name: "Insights", icon: "bar-chart-outline", isExternal: true, - onPress: () => openExternalLink("https://app.cal.com/insights", "Insights page"), + onPress: () => openInAppBrowser("https://app.cal.com/insights", "Insights page"), }, { name: "Support", icon: "help-circle-outline", isExternal: true, - onPress: () => openExternalLink("https://go.cal.com/support", "Support"), + onPress: () => openInAppBrowser("https://go.cal.com/support", "Support"), }, ]; @@ -99,14 +106,27 @@ export default function More() { ))} - {/* Authentication Info and Logout */} - - + {/* Sign Out Button */} + + + + Sign Out + - - We view the companion as an extension of the web application. If you are performing any - complicated actions, please refer back to the web application. + {/* Footer Note */} + + The companion app is an extension of the web application.{"\n"} + For advanced features, visit{" "} + openInAppBrowser("https://app.cal.com", "Cal.com")} + > + app.cal.com + diff --git a/companion/app/_layout.tsx b/companion/app/_layout.tsx index da89a2764f..dc1bd0c8a7 100644 --- a/companion/app/_layout.tsx +++ b/companion/app/_layout.tsx @@ -1,6 +1,8 @@ import { Stack } from "expo-router"; import { Platform, View, StatusBar } from "react-native"; import { AuthProvider, useAuth } from "../contexts/AuthContext"; +import { QueryProvider } from "../contexts/QueryContext"; +import { NetworkStatusBanner } from "../components/NetworkStatusBanner"; import LoginScreen from "../components/LoginScreen"; import "../global.css"; @@ -35,14 +37,17 @@ function RootLayoutContent() { {content} + ); } export default function RootLayout() { return ( - - - + + + + + ); } diff --git a/companion/app/availability-detail.tsx b/companion/app/availability-detail.tsx index 4e0f33ab8e..b09a64802d 100644 --- a/companion/app/availability-detail.tsx +++ b/companion/app/availability-detail.tsx @@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { CalComAPIService, Schedule } from "../services/calcom"; import { ScheduleAvailability } from "../services/types"; import { FullScreenModal } from "../components/FullScreenModal"; +import { showErrorAlert } from "../utils/alerts"; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; @@ -220,7 +221,7 @@ export default function AvailabilityDetail() { } } catch (error) { console.error("Error fetching schedule:", error); - Alert.alert("Error", "Failed to load schedule. Please try again."); + showErrorAlert("Error", "Failed to load schedule. Please try again."); router.back(); } finally { setLoading(false); @@ -348,7 +349,7 @@ export default function AvailabilityDetail() { { text: "OK", onPress: () => router.back() }, ]); } catch (error) { - Alert.alert("Error", "Failed to update schedule. Please try again."); + showErrorAlert("Error", "Failed to update schedule. Please try again."); } finally { setSaving(false); } @@ -362,7 +363,7 @@ export default function AvailabilityDetail() { setIsDefault(true); Alert.alert("Success", "Schedule set as default successfully"); } catch (error) { - Alert.alert("Error", "Failed to set schedule as default. Please try again."); + showErrorAlert("Error", "Failed to set schedule as default. Please try again."); } }; @@ -379,7 +380,7 @@ export default function AvailabilityDetail() { { text: "OK", onPress: () => router.back() }, ]); } catch (error) { - Alert.alert("Error", "Failed to delete schedule. Please try again."); + showErrorAlert("Error", "Failed to delete schedule. Please try again."); } }, }, diff --git a/companion/app/booking-detail.tsx b/companion/app/booking-detail.tsx index 821978e77e..a3d21d6876 100644 --- a/companion/app/booking-detail.tsx +++ b/companion/app/booking-detail.tsx @@ -8,16 +8,19 @@ import { TouchableOpacity, Alert, ActivityIndicator, - Linking, TextInput, Platform, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { CalComAPIService, Booking } from "../services/calcom"; +import { showErrorAlert } from "../utils/alerts"; +import { openInAppBrowser } from "../utils/browser"; import { SvgImage } from "../components/SvgImage"; import { FullScreenModal } from "../components/FullScreenModal"; +import { BookingActionsModal } from "../components/BookingActionsModal"; import { getAppIconUrl } from "../utils/getAppIconUrl"; import { getDefaultLocationIconUrl, defaultLocations } from "../utils/defaultLocations"; +import { formatAppIdToDisplayName } from "../utils/formatters"; // Format date: "Tuesday, November 25, 2025" const formatDateFull = (dateString: string): string => { @@ -137,14 +140,6 @@ const getLocationProvider = (location: string | undefined, metadata?: Record "Cal Video") - const formatAppIdToDisplayName = (id: string): string => { - return id - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - }; - return { label: formatAppIdToDisplayName(appId), iconUrl: iconUrl, @@ -181,6 +176,8 @@ export default function BookingDetail() { const [error, setError] = useState(null); const [showActionsModal, setShowActionsModal] = useState(false); const [showRescheduleModal, setShowRescheduleModal] = useState(false); + const [rescheduleDate, setRescheduleDate] = useState(""); + const [rescheduleTime, setRescheduleTime] = useState(""); const [rescheduleReason, setRescheduleReason] = useState(""); const [rescheduling, setRescheduling] = useState(false); @@ -208,9 +205,13 @@ export default function BookingDetail() { } catch (err) { console.error("Error fetching booking:", err); setError("Failed to load booking. Please try again."); - Alert.alert("Error", "Failed to load booking. Please try again.", [ - { text: "OK", onPress: () => router.back() }, - ]); + if (__DEV__) { + Alert.alert("Error", "Failed to load booking. Please try again.", [ + { text: "OK", onPress: () => router.back() }, + ]); + } else { + router.back(); + } } finally { setLoading(false); } @@ -221,31 +222,70 @@ export default function BookingDetail() { const provider = getLocationProvider(booking.location); if (provider?.url) { - Linking.openURL(provider.url); + openInAppBrowser(provider.url, "meeting link"); } }; - const handleReschedule = async () => { + const openRescheduleModal = () => { if (!booking) return; + // Pre-fill with the current booking date/time (using local timezone consistently) + const currentDate = new Date(booking.startTime); + const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`; + const timeStr = `${String(currentDate.getHours()).padStart(2, "0")}:${String(currentDate.getMinutes()).padStart(2, "0")}`; + + setRescheduleDate(dateStr); + setRescheduleTime(timeStr); + setRescheduleReason(""); + setShowRescheduleModal(true); + }; + + const handleReschedule = async () => { + if (!booking || !rescheduleDate || !rescheduleTime) { + showErrorAlert("Error", "Please enter both date and time"); + return; + } + + // Parse the date and time + const dateTimeStr = `${rescheduleDate}T${rescheduleTime}:00`; + const newDateTime = new Date(dateTimeStr); + + // Validate the date + if (isNaN(newDateTime.getTime())) { + showErrorAlert( + "Error", + "Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time." + ); + return; + } + + // Check if the new time is in the future + if (newDateTime <= new Date()) { + showErrorAlert("Error", "Please select a future date and time"); + return; + } + + // Convert to UTC ISO string + const startUtc = newDateTime.toISOString(); + setRescheduling(true); try { - // For now, we'll use the current start time - // In a full implementation, you'd show a date/time picker await CalComAPIService.rescheduleBooking(booking.uid, { - start: booking.startTime, + start: startUtc, reschedulingReason: rescheduleReason.trim() || undefined, }); - Alert.alert("Success", "Reschedule request sent successfully"); + Alert.alert("Success", "Booking rescheduled successfully"); setShowRescheduleModal(false); + setRescheduleDate(""); + setRescheduleTime(""); setRescheduleReason(""); // Refresh booking data await fetchBooking(); } catch (error) { console.error("Failed to reschedule booking:", error); - Alert.alert("Error", "Failed to send reschedule request. Please try again."); + showErrorAlert("Error", "Failed to reschedule booking. Please try again."); } finally { setRescheduling(false); } @@ -470,193 +510,51 @@ export default function BookingDetail() { {/* Booking Actions Modal */} - setShowActionsModal(false)} - > - setShowActionsModal(false)} - > - e.stopPropagation()} - > - {/* Header */} - - - Booking Actions - - - {/* Actions List */} - - {/* View Booking */} - { - setShowActionsModal(false); - // TODO: Navigate to booking view page - console.log("View booking"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - View Booking - - - {/* Separator */} - - - {/* Edit event label */} - - Edit event - - - {/* Request Reschedule */} - { - setShowActionsModal(false); - setShowRescheduleModal(true); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Send Reschedule Request - - - {/* Edit Location */} - { - setShowActionsModal(false); - // TODO: Open edit location dialog - console.log("Edit location"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Edit Location - - - {/* Add Guests */} - { - setShowActionsModal(false); - // TODO: Open add guests dialog - console.log("Add guests"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Add Guests - - - {/* Separator */} - - - {/* After event label */} - - After event - - - {/* View Recordings */} - {locationProvider?.url && ( - { - setShowActionsModal(false); - // TODO: Open view recordings dialog - console.log("View recordings"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - View Recordings - - )} - - {/* Meeting Session Details */} - {locationProvider?.url && ( - { - setShowActionsModal(false); - // TODO: Open session details dialog - console.log("Meeting session details"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Meeting Session Details - - )} - - {/* Mark as No-Show */} - { - setShowActionsModal(false); - // TODO: Mark as no-show - console.log("Mark as no-show"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Mark as No-Show - - - {/* Separator */} - - - {/* Report Booking */} - { - setShowActionsModal(false); - // TODO: Open report booking dialog - console.log("Report booking"); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Report Booking - - - {/* Separator */} - - - {/* Cancel Booking */} - { - setShowActionsModal(false); - Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [ - { text: "No", style: "cancel" }, - { - text: "Yes, Cancel", - style: "destructive", - onPress: () => { - // TODO: Cancel booking - console.log("Cancel booking"); - }, - }, - ]); - }} - className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" - > - - Cancel Booking - - - - {/* Cancel button */} - - setShowActionsModal(false)} - > - Cancel - - - - - + onClose={() => setShowActionsModal(false)} + booking={booking} + hasLocationUrl={!!locationProvider?.url} + isUpcoming={booking ? new Date(booking.startTime) > new Date() : false} + isPast={booking ? new Date(booking.startTime) <= new Date() : false} + isCancelled={booking?.status?.toUpperCase() === "CANCELLED"} + isUnconfirmed={booking?.status?.toUpperCase() === "PENDING"} + onReschedule={openRescheduleModal} + onEditLocation={() => { + Alert.alert("Edit Location", "Edit location functionality coming soon"); + }} + onAddGuests={() => { + Alert.alert("Add Guests", "Add guests functionality coming soon"); + }} + onViewRecordings={() => { + Alert.alert("View Recordings", "View recordings functionality coming soon"); + }} + onMeetingSessionDetails={() => { + Alert.alert( + "Meeting Session Details", + "Meeting session details functionality coming soon" + ); + }} + onMarkNoShow={() => { + Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon"); + }} + onReportBooking={() => { + Alert.alert("Report Booking", "Report booking functionality coming soon"); + }} + onCancelBooking={() => { + Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [ + { text: "No", style: "cancel" }, + { + text: "Yes, Cancel", + style: "destructive", + onPress: () => { + // TODO: Implement cancel booking + console.log("Cancel booking"); + }, + }, + ]); + }} + /> {/* Reschedule Modal */} - + - Reschedule request + Reschedule Booking - Send a reschedule request to the organizer of this booking. + Select a new date and time for this booking. {/* Content */} - - - Reason for reschedule request - (Optional) - - - + + {/* Date Input */} + + + New Date + (YYYY-MM-DD) + + + + + {/* Time Input */} + + + New Time + (HH:MM, 24-hour) + + + + + {/* Reason Input */} + + + Reason + (Optional) + + + + {/* Footer */} @@ -724,6 +659,8 @@ export default function BookingDetail() { className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4" onPress={() => { setShowRescheduleModal(false); + setRescheduleDate(""); + setRescheduleTime(""); setRescheduleReason(""); }} disabled={rescheduling} @@ -735,7 +672,11 @@ export default function BookingDetail() { onPress={handleReschedule} disabled={rescheduling} > - Reschedule request + {rescheduling ? ( + + ) : ( + Reschedule + )} diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx index 3991df5b37..d7970e2f83 100644 --- a/companion/app/event-type-detail.tsx +++ b/companion/app/event-type-detail.tsx @@ -1,5 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; -import { GlassView } from "expo-glass-effect"; +import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { useRouter, useLocalSearchParams, Stack } from "expo-router"; import React, { useState, useEffect } from "react"; import { @@ -10,36 +10,45 @@ import { TextInput, Switch, Modal, - Linking, Alert, - Clipboard, Animated, Image, Platform, } from "react-native"; +import * as Clipboard from "expo-clipboard"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { CalComAPIService, Schedule, ConferencingOption, EventType } from "../services/calcom"; -import { getAppIconUrl } from "../utils/getAppIconUrl"; +import { showErrorAlert } from "../utils/alerts"; +import { openInAppBrowser } from "../utils/browser"; +import { LocationItem, LocationOptionGroup } from "../types/locations"; import { - defaultLocations, - getDefaultLocationIconUrl, - isDefaultLocation, - DefaultLocationType, -} from "../utils/defaultLocations"; -import { SvgImage } from "../components/SvgImage"; + mapApiLocationToItem, + mapItemToApiLocation, + buildLocationOptions, + validateLocationItem, +} from "../utils/locationHelpers"; import { parseBufferTime, parseMinimumNotice, parseFrequencyUnit, parseSlotInterval, -} from "../utils/parsers/event-type-parsers"; +} from "../utils/eventTypeParsers"; import { slugify } from "../utils/slugify"; -import { BasicsTab } from "./event-type-detail/tabs/BasicsTab"; -import { AvailabilityTab } from "./event-type-detail/tabs/AvailabilityTab"; -import { LimitsTab } from "./event-type-detail/tabs/LimitsTab"; -import { AdvancedTab } from "./event-type-detail/tabs/AdvancedTab"; -import { RecurringTab } from "./event-type-detail/tabs/RecurringTab"; +import { BasicsTab } from "../components/event-type-detail/tabs/BasicsTab"; +import { AvailabilityTab } from "../components/event-type-detail/tabs/AvailabilityTab"; +import { LimitsTab } from "../components/event-type-detail/tabs/LimitsTab"; +import { AdvancedTab } from "../components/event-type-detail/tabs/AdvancedTab"; +import { RecurringTab } from "../components/event-type-detail/tabs/RecurringTab"; +import { + formatDuration, + truncateTitle, + formatAppIdToDisplayName, +} from "../components/event-type-detail/utils"; +import { + buildPartialUpdatePayload, + hasChanges, +} from "../components/event-type-detail/utils/buildPartialUpdatePayload"; const tabs = [ { id: "basics", label: "Basics", icon: "link" }, @@ -47,9 +56,7 @@ const tabs = [ { id: "limits", label: "Limits", icon: "time" }, { id: "advanced", label: "Advanced", icon: "settings" }, { id: "recurring", label: "Recurring", icon: "refresh" }, - { id: "apps", label: "Apps", icon: "grid" }, - { id: "workflows", label: "Workflows", icon: "flash" }, - { id: "webhooks", label: "Webhooks", icon: "code" }, + { id: "other", label: "Other", icon: "ellipsis-horizontal" }, ]; export default function EventTypeDetail() { @@ -74,8 +81,7 @@ export default function EventTypeDetail() { const [eventDuration, setEventDuration] = useState(duration || "30"); const [username, setUsername] = useState("username"); const [allowMultipleDurations, setAllowMultipleDurations] = useState(false); - const [selectedLocation, setSelectedLocation] = useState(""); - const [showLocationDropdown, setShowLocationDropdown] = useState(false); + const [locations, setLocations] = useState([]); const [locationAddress, setLocationAddress] = useState(""); const [locationLink, setLocationLink] = useState(""); const [locationPhone, setLocationPhone] = useState(""); @@ -116,6 +122,7 @@ export default function EventTypeDetail() { const [onlyShowFirstAvailableSlot, setOnlyShowFirstAvailableSlot] = useState(false); const [maxActiveBookingsPerBooker, setMaxActiveBookingsPerBooker] = useState(false); const [maxActiveBookingsValue, setMaxActiveBookingsValue] = useState("1"); + const [offerReschedule, setOfferReschedule] = useState(false); const [limitFutureBookings, setLimitFutureBookings] = useState(false); const [futureBookingType, setFutureBookingType] = useState<"rolling" | "range">("rolling"); const [rollingDays, setRollingDays] = useState("30"); @@ -142,19 +149,26 @@ export default function EventTypeDetail() { const [forwardParamsSuccessRedirect, setForwardParamsSuccessRedirect] = useState(false); const [hideOrganizerEmail, setHideOrganizerEmail] = useState(false); const [lockTimezone, setLockTimezone] = useState(false); + const [lockedTimezone, setLockedTimezone] = useState("Europe/London"); const [allowReschedulingPastEvents, setAllowReschedulingPastEvents] = useState(false); const [allowBookingThroughRescheduleLink, setAllowBookingThroughRescheduleLink] = useState(false); + const [disableGuests, setDisableGuests] = useState(false); const [customReplyToEmail, setCustomReplyToEmail] = useState(""); const [eventTypeColorLight, setEventTypeColorLight] = useState("#292929"); const [eventTypeColorDark, setEventTypeColorDark] = useState("#FAFAFA"); - // Recurring tab state + const [seatsEnabled, setSeatsEnabled] = useState(false); + const [seatsPerTimeSlot, setSeatsPerTimeSlot] = useState("2"); + const [showAttendeeInfo, setShowAttendeeInfo] = useState(false); + const [showAvailabilityCount, setShowAvailabilityCount] = useState(true); + const [recurringEnabled, setRecurringEnabled] = useState(false); const [recurringInterval, setRecurringInterval] = useState("1"); - const [recurringFrequency, setRecurringFrequency] = useState< - "daily" | "weekly" | "monthly" | "yearly" - >("weekly"); + const [recurringFrequency, setRecurringFrequency] = useState<"weekly" | "monthly" | "yearly">( + "weekly" + ); const [recurringOccurrences, setRecurringOccurrences] = useState("12"); + const [showRecurringFrequencyDropdown, setShowRecurringFrequencyDropdown] = useState(false); const bufferTimeOptions = [ "No buffer time", @@ -208,185 +222,28 @@ export default function EventTypeDetail() { "480 mins", ]; - const formatDuration = (minutes: string) => { - const mins = parseInt(minutes) || 0; - if (mins < 60) return `${mins}m`; - const hours = Math.floor(mins / 60); - const remainingMins = mins % 60; - return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`; + const getLocationOptionsForDropdown = (): LocationOptionGroup[] => { + return buildLocationOptions(conferencingOptions); }; - const truncateTitle = (text: string, maxLength: number = 20) => { - return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; + const handleAddLocation = (location: LocationItem) => { + setLocations((prev) => [...prev, location]); }; - const formatAppIdToDisplayName = (appId: string): string => { - // Convert appId like "google-meet" to "Google Meet" - return appId - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); + const handleRemoveLocation = (locationId: string) => { + setLocations((prev) => prev.filter((loc) => loc.id !== locationId)); }; - const displayNameToLocationValue = ( - displayName: string - ): { - type: string; - integration?: string; - address?: string; - link?: string; - phone?: string; - public?: boolean; - } | null => { - // First check if it's a default location - const defaultLocation = defaultLocations.find((loc) => loc.label === displayName); - if (defaultLocation) { - // Map internal location types to API location types - switch (defaultLocation.type) { - case "attendeeInPerson": - return { type: "attendeeAddress" }; - case "inPerson": - // For organizer address, we need to preserve existing address if available - // or use empty string (will be filled by organizer later) - return { type: "address", address: "", public: true }; - case "link": - // For link meeting, we need to preserve existing link if available - // or use empty string (will be filled by organizer later) - return { type: "link", link: "", public: true }; - case "phone": - return { type: "attendeePhone" }; - case "userPhone": - // For organizer phone, we need to preserve existing phone if available - // or use empty string (will be filled by organizer later) - return { type: "phone", phone: "", public: true }; - case "somewhereElse": - return { type: "attendeeDefined" }; - default: - return { type: defaultLocation.type }; - } - } - - // Otherwise, find matching conferencing option - const option = conferencingOptions.find( - (opt) => formatAppIdToDisplayName(opt.appId) === displayName + const handleUpdateLocation = (locationId: string, updates: Partial) => { + setLocations((prev) => + prev.map((loc) => (loc.id === locationId ? { ...loc, ...updates } : loc)) ); - if (option) { - return { type: "integration", integration: option.appId, public: true }; - } - - return null; - }; - - const displayNameToAppId = (displayName: string): string | null => { - // Legacy function for backward compatibility - only for conferencing apps - const option = conferencingOptions.find( - (opt) => formatAppIdToDisplayName(opt.appId) === displayName - ); - return option ? option.appId : null; - }; - - type LocationOption = { label: string; iconUrl: string | null; value: string }; - type LocationGroup = { category: string; options: LocationOption[] }; - - const getLocationOptions = (): LocationGroup[] => { - // Group conferencing apps under "conferencing" category - const conferencingAppOptions: LocationOption[] = conferencingOptions.map((option) => { - const iconUrl = getAppIconUrl(option.type, option.appId); - return { - label: formatAppIdToDisplayName(option.appId), - iconUrl: iconUrl, - value: `integrations:${option.appId}`, - }; - }); - - // Group default locations by their category - const defaultLocationOptions: LocationOption[] = defaultLocations.map((location) => ({ - label: location.label, - iconUrl: location.iconUrl, - value: location.type, - })); - - // Group options by category - const grouped: Record = {}; - - // Add conferencing apps - if (conferencingAppOptions.length > 0) { - grouped["conferencing"] = conferencingAppOptions; - } - - // Add default locations by category - defaultLocations.forEach((location) => { - const category = location.category; - if (!grouped[category]) { - grouped[category] = []; - } - const option = defaultLocationOptions.find((opt) => opt.value === location.type); - if (option) { - grouped[category].push(option); - } - }); - - // Convert to array format with category labels - const categoryLabels: Record = { - conferencing: "Conferencing", - "in person": "In Person", - phone: "Phone", - other: "Other", - }; - - const result: LocationGroup[] = []; - for (const category in grouped) { - if (grouped[category].length > 0) { - result.push({ - category: categoryLabels[category] || category, - options: grouped[category], - }); - } - } - - console.log( - "Total location groups:", - result.length, - result.map((g) => `${g.category}: ${g.options.length}`) - ); - return result; }; const getSelectedLocationIconUrl = (): string | null => { - if (!selectedLocation) return null; - - // First, check if it's a default location - const defaultLocation = defaultLocations.find((loc) => loc.label === selectedLocation); - if (defaultLocation) { - return defaultLocation.iconUrl; + if (locations.length > 0) { + return locations[0].iconUrl; } - - // Try to find in conferencing options - const option = conferencingOptions.find( - (opt) => formatAppIdToDisplayName(opt.appId) === selectedLocation - ); - if (option) { - return getAppIconUrl(option.type, option.appId); - } - - // Fallback: Handle Cal Video directly (it might not be in conferencing options as it's a global app) - // Check if selectedLocation matches Cal Video display names - const calVideoNames = ["Cal Video", "Cal-Video", "cal-video"]; - if ( - calVideoNames.includes(selectedLocation) || - selectedLocation.toLowerCase().includes("cal video") - ) { - return getAppIconUrl("daily_video", "cal-video"); - } - - // Fallback: Try to reverse the formatAppIdToDisplayName to get appId - // Convert "Cal Video" back to "cal-video" and try to get icon - const reverseAppId = selectedLocation.toLowerCase().replace(/\s+/g, "-"); - const fallbackIconUrl = getAppIconUrl("", reverseAppId); - if (fallbackIconUrl) { - return fallbackIconUrl; - } - return null; }; @@ -475,9 +332,7 @@ export default function EventTypeDetail() { const fetchScheduleDetails = async (scheduleId: number) => { try { setScheduleDetailsLoading(true); - console.log("Fetching schedule details for ID:", scheduleId); const scheduleDetails = await CalComAPIService.getScheduleById(scheduleId); - console.log("Raw schedule details response:", scheduleDetails); setSelectedScheduleDetails(scheduleDetails); if (scheduleDetails.timeZone) { setSelectedTimezone(scheduleDetails.timeZone); @@ -494,7 +349,6 @@ export default function EventTypeDetail() { try { setConferencingLoading(true); const options = await CalComAPIService.getConferencingOptions(); - console.log("Fetched conferencing options:", JSON.stringify(options, null, 2)); setConferencingOptions(options); } catch (error) { console.error("Failed to fetch conferencing options:", error); @@ -518,6 +372,22 @@ export default function EventTypeDetail() { if (eventType.lengthInMinutes) setEventDuration(eventType.lengthInMinutes.toString()); if (eventType.hidden !== undefined) setIsHidden(eventType.hidden); + const eventTypeAny = eventType as any; + if ( + eventTypeAny.lengthInMinutesOptions && + Array.isArray(eventTypeAny.lengthInMinutesOptions) && + eventTypeAny.lengthInMinutesOptions.length > 0 + ) { + setAllowMultipleDurations(true); + const durationStrings = eventTypeAny.lengthInMinutesOptions.map( + (mins: number) => `${mins} mins` + ); + setSelectedDurations(durationStrings); + if (eventType.lengthInMinutes) { + setDefaultDuration(`${eventType.lengthInMinutes} mins`); + } + } + // Load buffer times if (eventType.beforeEventBuffer) { setBeforeEventBuffer(`${eventType.beforeEventBuffer} Minutes`); @@ -630,16 +500,20 @@ export default function EventTypeDetail() { setOnlyShowFirstAvailableSlot(eventType.onlyShowFirstAvailableSlot); } - // Load max active bookings - if ( - eventType.bookerActiveBookingsLimit && - !("disabled" in eventType.bookerActiveBookingsLimit) - ) { - setMaxActiveBookingsPerBooker(true); - setMaxActiveBookingsValue(eventType.bookerActiveBookingsLimit.count.toString()); + if (eventType.bookerActiveBookingsLimit) { + const bookingLimit = eventType.bookerActiveBookingsLimit as any; + if (!("disabled" in bookingLimit)) { + const maxBookings = bookingLimit.maximumActiveBookings ?? bookingLimit.count; + if (maxBookings !== undefined) { + setMaxActiveBookingsPerBooker(true); + setMaxActiveBookingsValue(maxBookings.toString()); + } + if (bookingLimit.offerReschedule !== undefined) { + setOfferReschedule(bookingLimit.offerReschedule); + } + } } - // Load booking window (future bookings limit) if (eventType.bookingWindow && !("disabled" in eventType.bookingWindow)) { setLimitFutureBookings(true); if (eventType.bookingWindow.type === "range") { @@ -660,7 +534,32 @@ export default function EventTypeDetail() { } } - // Load Advanced tab fields + const eventTypeAnyForAdvanced = eventType as any; + + if (eventTypeAnyForAdvanced.disableCancelling !== undefined) { + setDisableCancelling(eventTypeAnyForAdvanced.disableCancelling); + } else if (eventType.metadata?.disableCancelling) { + setDisableCancelling(true); + } + + if (eventTypeAnyForAdvanced.disableRescheduling !== undefined) { + setDisableRescheduling(eventTypeAnyForAdvanced.disableRescheduling); + } else if (eventType.metadata?.disableRescheduling) { + setDisableRescheduling(true); + } + + if (eventTypeAnyForAdvanced.sendCalVideoTranscription !== undefined) { + setSendCalVideoTranscription(eventTypeAnyForAdvanced.sendCalVideoTranscription); + } else if (eventType.metadata?.sendCalVideoTranscription) { + setSendCalVideoTranscription(true); + } + + if (eventTypeAnyForAdvanced.autoTranslate !== undefined) { + setAutoTranslate(eventTypeAnyForAdvanced.autoTranslate); + } else if (eventType.metadata?.autoTranslate) { + setAutoTranslate(true); + } + if (eventType.metadata) { if (eventType.metadata.calendarEventName) { setCalendarEventName(eventType.metadata.calendarEventName); @@ -668,33 +567,6 @@ export default function EventTypeDetail() { if (eventType.metadata.addToCalendarEmail) { setAddToCalendarEmail(eventType.metadata.addToCalendarEmail); } - if (eventType.metadata.customReplyToEmail) { - setCustomReplyToEmail(eventType.metadata.customReplyToEmail); - } - if (eventType.metadata.disableCancelling) { - setDisableCancelling(true); - } - if (eventType.metadata.disableRescheduling) { - setDisableRescheduling(true); - } - if (eventType.metadata.sendCalVideoTranscription) { - setSendCalVideoTranscription(true); - } - if (eventType.metadata.autoTranslate) { - setAutoTranslate(true); - } - if (eventType.metadata.hideCalendarEventDetails) { - setHideCalendarEventDetails(true); - } - if (eventType.metadata.hideOrganizerEmail) { - setHideOrganizerEmail(true); - } - if (eventType.metadata.allowReschedulingPastEvents) { - setAllowReschedulingPastEvents(true); - } - if (eventType.metadata.allowBookingThroughRescheduleLink) { - setAllowBookingThroughRescheduleLink(true); - } } // Load booker layouts @@ -710,12 +582,16 @@ export default function EventTypeDetail() { } } - // Load confirmation settings + if (eventType.confirmationPolicy) { + const policy = eventType.confirmationPolicy as any; + if (!("disabled" in policy) || policy.disabled === false) { + setRequiresConfirmation(true); + } + } if (eventType.requiresConfirmation !== undefined) { setRequiresConfirmation(eventType.requiresConfirmation); } - // Load other boolean fields if (eventType.requiresBookerEmailVerification !== undefined) { setRequiresBookerEmailVerification(eventType.requiresBookerEmailVerification); } @@ -725,16 +601,32 @@ export default function EventTypeDetail() { if (eventType.lockTimeZoneToggleOnBookingPage !== undefined) { setLockTimezone(eventType.lockTimeZoneToggleOnBookingPage); } + if (eventTypeAny.lockedTimeZone) { + setLockedTimezone(eventTypeAny.lockedTimeZone); + } + if (eventTypeAny.hideCalendarEventDetails !== undefined) { + setHideCalendarEventDetails(eventTypeAny.hideCalendarEventDetails); + } + if (eventTypeAny.hideOrganizerEmail !== undefined) { + setHideOrganizerEmail(eventTypeAny.hideOrganizerEmail); + } // Load redirect URL if (eventType.successRedirectUrl) { setSuccessRedirectUrl(eventType.successRedirectUrl); - if (eventType.forwardParamsSuccessRedirect !== undefined) { - setForwardParamsSuccessRedirect(eventType.forwardParamsSuccessRedirect); - } + } + if (eventType.forwardParamsSuccessRedirect !== undefined) { + setForwardParamsSuccessRedirect(eventType.forwardParamsSuccessRedirect); } - // Load event type colors + if (eventTypeAny.color) { + if (eventTypeAny.color.lightThemeHex) { + setEventTypeColorLight(eventTypeAny.color.lightThemeHex); + } + if (eventTypeAny.color.darkThemeHex) { + setEventTypeColorDark(eventTypeAny.color.darkThemeHex); + } + } if (eventType.eventTypeColor) { if (eventType.eventTypeColor.lightEventTypeColor) { setEventTypeColorLight(eventType.eventTypeColor.lightEventTypeColor); @@ -744,73 +636,54 @@ export default function EventTypeDetail() { } } - // Load recurring event settings - if (eventType.recurrence && !("disabled" in eventType.recurrence)) { - setRecurringEnabled(true); - setRecurringInterval(eventType.recurrence.interval.toString()); - setRecurringFrequency(eventType.recurrence.frequency); - setRecurringOccurrences(eventType.recurrence.occurrences.toString()); + if (eventType.recurrence) { + const recurrence = eventType.recurrence as any; + if (recurrence.disabled !== true && recurrence.interval && recurrence.frequency) { + setRecurringEnabled(true); + setRecurringInterval(recurrence.interval.toString()); + const freq = recurrence.frequency as "weekly" | "monthly" | "yearly"; + if (freq === "weekly" || freq === "monthly" || freq === "yearly") { + setRecurringFrequency(freq); + } + setRecurringOccurrences(recurrence.occurrences?.toString() || "12"); + } } - // Extract location from event type if (eventType.locations && eventType.locations.length > 0) { + const mappedLocations = eventType.locations.map((loc: any) => mapApiLocationToItem(loc)); + setLocations(mappedLocations); + const firstLocation = eventType.locations[0]; - - // Handle conferencing apps (with integration field) - if (firstLocation.integration) { - // Format the integration name (e.g., "google-meet" -> "Google Meet") - const formattedLocation = formatAppIdToDisplayName(firstLocation.integration); - setSelectedLocation(formattedLocation); + if (firstLocation.address) { + setLocationAddress(firstLocation.address); } - // Handle default locations (with type field) - else if (firstLocation.type) { - // Map API location types to internal location types - // Need to distinguish between organizer and attendee types based on presence of fields - let internalType = firstLocation.type; + if (firstLocation.link) { + setLocationLink(firstLocation.link); + } + if (firstLocation.phone) { + setLocationPhone(firstLocation.phone); + } + } - // Check if it's an organizer type (has address, link, or phone field) - if ( - firstLocation.type === "address" || - (firstLocation.type === "phone" && firstLocation.phone) - ) { - // Organizer types - if (firstLocation.type === "address") { - internalType = "inPerson"; // Organizer address - } else if (firstLocation.type === "phone" && firstLocation.phone) { - internalType = "userPhone"; // Organizer phone - } - } else if (firstLocation.type === "link") { - internalType = "link"; // Link meeting - } else { - // Attendee types - map API types to internal types - const apiToInternalTypeMap: Record = { - attendeeAddress: "attendeeInPerson", - attendeePhone: "phone", - attendeeDefined: "somewhereElse", - }; + if (eventType.disableGuests !== undefined) { + setDisableGuests(eventType.disableGuests); + } - if (apiToInternalTypeMap[firstLocation.type]) { - internalType = apiToInternalTypeMap[firstLocation.type]; - } + if (eventType.seats) { + const seats = eventType.seats as any; + const seatsAreEnabled = + seats.disabled === false || (!("disabled" in seats) && seats.seatsPerTimeSlot); + + if (seatsAreEnabled) { + setSeatsEnabled(true); + if (seats.seatsPerTimeSlot) { + setSeatsPerTimeSlot(seats.seatsPerTimeSlot.toString()); } - - const defaultLocation = defaultLocations.find((loc) => loc.type === internalType); - if (defaultLocation) { - setSelectedLocation(defaultLocation.label); - - // Populate location input values if they exist - if (firstLocation.address) { - setLocationAddress(firstLocation.address); - } - if (firstLocation.link) { - setLocationLink(firstLocation.link); - } - if (firstLocation.phone) { - setLocationPhone(firstLocation.phone); - } - } else { - // Fallback: try to format the type as display name - setSelectedLocation(firstLocation.type); + if (seats.showAttendeeInfo !== undefined) { + setShowAttendeeInfo(seats.showAttendeeInfo); + } + if (seats.showAvailabilityCount !== undefined) { + setShowAvailabilityCount(seats.showAvailabilityCount); } } } @@ -874,13 +747,9 @@ export default function EventTypeDetail() { const getDaySchedule = () => { if (!selectedScheduleDetails) { - console.log("No selectedScheduleDetails"); return []; } - console.log("selectedScheduleDetails:", selectedScheduleDetails); - console.log("availability:", selectedScheduleDetails.availability); - const daysOfWeek = [ "Sunday", "Monday", @@ -912,8 +781,6 @@ export default function EventTypeDetail() { ); }); - console.log(`${day}: availability found:`, availability); - return { day, available: !!availability, @@ -929,16 +796,10 @@ export default function EventTypeDetail() { try { const eventTypeSlug = eventSlug || "preview"; const link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); - - const supported = await Linking.canOpenURL(link); - if (supported) { - await Linking.openURL(link); - } else { - Alert.alert("Error", "Cannot open this URL on your device."); - } + await openInAppBrowser(link, "event type preview"); } catch (error) { console.error("Failed to generate preview link:", error); - Alert.alert("Error", "Failed to generate preview link. Please try again."); + showErrorAlert("Error", "Failed to generate preview link. Please try again."); } }; @@ -947,11 +808,11 @@ export default function EventTypeDetail() { const eventTypeSlug = eventSlug || "event-link"; const link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); - Clipboard.setString(link); + await Clipboard.setStringAsync(link); Alert.alert("Success", "Link copied!"); } catch (error) { console.error("Failed to copy link:", error); - Alert.alert("Error", "Failed to copy link. Please try again."); + showErrorAlert("Error", "Failed to copy link. Please try again."); } }; @@ -963,7 +824,6 @@ export default function EventTypeDetail() { style: "destructive", onPress: async () => { try { - console.log("Attempting to delete event type with ID:", id); const eventTypeId = parseInt(id); if (isNaN(eventTypeId)) { @@ -971,21 +831,17 @@ export default function EventTypeDetail() { } await CalComAPIService.deleteEventType(eventTypeId); - console.log("Event type deleted successfully"); Alert.alert("Success", "Event type deleted successfully", [ { text: "OK", - onPress: () => { - console.log("Navigating back after successful deletion"); - router.back(); - }, + onPress: () => router.back(), }, ]); } catch (error) { console.error("Failed to delete event type:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - Alert.alert("Error", `Failed to delete event type: ${errorMessage}`); + showErrorAlert("Error", `Failed to delete event type: ${errorMessage}`); } }, }, @@ -1010,251 +866,47 @@ export default function EventTypeDetail() { return; } + // Validate locations before saving + if (locations.length > 0) { + for (const loc of locations) { + const validation = validateLocationItem(loc); + if (!validation.valid) { + Alert.alert("Error", validation.error || "Invalid location"); + return; + } + } + } + + // Detect create vs update mode + const isCreateMode = id === "new"; + try { setSaving(true); - // Build location payload if a location is selected - let locationsPayload: - | Array<{ - type: string; - integration?: string; - address?: string; - link?: string; - phone?: string; - public?: boolean; - }> - | undefined; - - if (selectedLocation) { - const locationValue = displayNameToLocationValue(selectedLocation); - if (!locationValue) { - Alert.alert("Error", "Invalid location selected"); - setSaving(false); - return; - } - - // Get existing location data to preserve values (address, link, phone) - const existingLocation = eventTypeData?.locations?.[0]; - - // Build location payload based on type - const locationPayload: { - type: string; - integration?: string; - address?: string; - link?: string; - phone?: string; - public?: boolean; - } = { - type: locationValue.type, - }; - - // Add type-specific fields based on location type - if (locationValue.type === "integration") { - locationPayload.integration = locationValue.integration; - locationPayload.public = locationValue.public ?? true; - } else if (locationValue.type === "address") { - locationPayload.address = locationAddress || existingLocation?.address || ""; - locationPayload.public = true; - } else if (locationValue.type === "link") { - locationPayload.link = locationLink || existingLocation?.link || ""; - locationPayload.public = true; - } else if (locationValue.type === "phone") { - locationPayload.phone = locationPhone || existingLocation?.phone || ""; - locationPayload.public = true; - } - - locationsPayload = [locationPayload]; - } - - // Build the payload with all fields - const payload: any = { - title: eventTitle, - slug: eventSlug, - lengthInMinutes: durationNum, - }; - - // Add optional fields if they have values - if (eventDescription) { - payload.description = eventDescription; - } - - if (locationsPayload) { - payload.locations = locationsPayload; - } - - if (selectedSchedule) { - payload.scheduleId = selectedSchedule.id; - } - - if (isHidden !== undefined) { - payload.hidden = isHidden; - } - - // Add buffer times if set - if (beforeEventBuffer && beforeEventBuffer !== "No buffer time") { - const bufferMinutes = parseBufferTime(beforeEventBuffer); - if (bufferMinutes > 0) { - payload.beforeEventBuffer = bufferMinutes; - } - } - - if (afterEventBuffer && afterEventBuffer !== "No buffer time") { - const bufferMinutes = parseBufferTime(afterEventBuffer); - if (bufferMinutes > 0) { - payload.afterEventBuffer = bufferMinutes; - } - } - - // Add minimum booking notice - if (minimumNoticeValue && minimumNoticeUnit) { - const noticeMinutes = parseMinimumNotice(minimumNoticeValue, minimumNoticeUnit); - if (noticeMinutes > 0) { - payload.minimumBookingNotice = noticeMinutes; - } - } - - // Add booking limits if enabled - if (limitBookingFrequency && frequencyLimits.length > 0) { - const limitsCount: any = {}; - frequencyLimits.forEach((limit) => { - const unit = parseFrequencyUnit(limit.unit); - if (unit) { - limitsCount[unit] = parseInt(limit.value) || 1; - } - }); - if (Object.keys(limitsCount).length > 0) { - payload.bookingLimitsCount = limitsCount; - } - } - - if (limitTotalDuration && durationLimits.length > 0) { - const limitsDuration: any = {}; - durationLimits.forEach((limit) => { - const unit = parseFrequencyUnit(limit.unit); - if (unit) { - limitsDuration[unit] = parseInt(limit.value) || 60; - } - }); - if (Object.keys(limitsDuration).length > 0) { - payload.bookingLimitsDuration = limitsDuration; - } - } - - // Add slot interval if not default - if (slotInterval && slotInterval !== "Default") { - const intervalMinutes = parseSlotInterval(slotInterval); - if (intervalMinutes > 0) { - payload.slotInterval = intervalMinutes; - } - } - - // Add other boolean flags - if (onlyShowFirstAvailableSlot) { - payload.onlyShowFirstAvailableSlot = true; - } - - if (maxActiveBookingsPerBooker && maxActiveBookingsValue) { - const count = parseInt(maxActiveBookingsValue); - if (count > 0) { - payload.bookerActiveBookingsLimit = { count }; - } - } - - // Add booking window (future bookings limit) - if (limitFutureBookings) { - if (futureBookingType === "range") { - payload.bookingWindow = { - type: "range", - value: [rangeStartDate, rangeEndDate], - }; - } else { - payload.bookingWindow = { - type: rollingCalendarDays ? "calendarDays" : "businessDays", - value: parseInt(rollingDays), - rolling: true, - }; - } - } else { - payload.bookingWindow = { disabled: true }; - } - - // Add Advanced tab fields - if (calendarEventName) { - payload.metadata = payload.metadata || {}; - payload.metadata.calendarEventName = calendarEventName; - } - - if (addToCalendarEmail) { - payload.metadata = payload.metadata || {}; - payload.metadata.addToCalendarEmail = addToCalendarEmail; - } - - // Booker layouts - if (selectedLayouts.length > 0) { - payload.bookerLayouts = { - enabledLayouts: selectedLayouts, - defaultLayout: defaultLayout, - }; - } - - // Confirmation policy - payload.requiresConfirmation = requiresConfirmation; - - // Boolean flags - always set the actual boolean value so toggling off works - payload.metadata = { - ...(payload.metadata || {}), - disableCancelling, - disableRescheduling, - sendCalVideoTranscription, - autoTranslate, - hideCalendarEventDetails, - hideOrganizerEmail, - allowReschedulingPastEvents, - allowBookingThroughRescheduleLink, - }; - payload.requiresBookerEmailVerification = requiresBookerEmailVerification; - payload.hideCalendarNotes = hideCalendarNotes; - payload.lockTimeZoneToggleOnBookingPage = lockTimezone; - - // Redirect URL - if (successRedirectUrl) { - payload.successRedirectUrl = successRedirectUrl; - if (forwardParamsSuccessRedirect) { - payload.forwardParamsSuccessRedirect = true; - } - } - - // Custom reply-to email - if (customReplyToEmail) { - payload.metadata = payload.metadata || {}; - payload.metadata.customReplyToEmail = customReplyToEmail; - } - - // Event type colors - if (eventTypeColorLight || eventTypeColorDark) { - payload.eventTypeColor = { - lightEventTypeColor: eventTypeColorLight, - darkEventTypeColor: eventTypeColorDark, - }; - } - - // Recurring event - if (recurringEnabled) { - payload.recurrence = { - interval: parseInt(recurringInterval) || 1, - occurrences: parseInt(recurringOccurrences) || 12, - frequency: recurringFrequency, - }; - } else { - payload.recurrence = { disabled: true }; - } - - // Detect create vs update mode - const isCreateMode = id === "new"; - if (isCreateMode) { + // For CREATE mode, build full payload + const payload: any = { + title: eventTitle, + slug: eventSlug, + lengthInMinutes: durationNum, + }; + + if (eventDescription) { + payload.description = eventDescription; + } + + if (locations.length > 0) { + payload.locations = locations.map((loc) => mapItemToApiLocation(loc)); + } + + if (selectedSchedule) { + payload.scheduleId = selectedSchedule.id; + } + + payload.hidden = isHidden; + // Create new event type - const newEventType = await CalComAPIService.createEventType(payload); + await CalComAPIService.createEventType(payload); Alert.alert("Success", "Event type created successfully", [ { text: "OK", @@ -1262,16 +914,99 @@ export default function EventTypeDetail() { }, ]); } else { - // Update existing event type + // For UPDATE mode, use partial update - only send changed fields + const currentFormState = { + // Basics + eventTitle, + eventSlug, + eventDescription, + eventDuration, + isHidden, + locations, + disableGuests, + + // Multiple durations + allowMultipleDurations, + selectedDurations, + defaultDuration, + + // Availability + selectedScheduleId: selectedSchedule?.id, + + // Limits + beforeEventBuffer, + afterEventBuffer, + minimumNoticeValue, + minimumNoticeUnit, + slotInterval, + limitBookingFrequency, + frequencyLimits, + limitTotalDuration, + durationLimits, + onlyShowFirstAvailableSlot, + maxActiveBookingsPerBooker, + maxActiveBookingsValue, + offerReschedule, + limitFutureBookings, + futureBookingType, + rollingDays, + rollingCalendarDays, + rangeStartDate, + rangeEndDate, + + // Advanced + requiresConfirmation, + requiresBookerEmailVerification, + hideCalendarNotes, + hideCalendarEventDetails, + hideOrganizerEmail, + lockTimezone, + allowReschedulingPastEvents, + allowBookingThroughRescheduleLink, + successRedirectUrl, + forwardParamsSuccessRedirect, + customReplyToEmail, + eventTypeColorLight, + eventTypeColorDark, + calendarEventName, + addToCalendarEmail, + selectedLayouts, + defaultLayout, + disableCancelling, + disableRescheduling, + sendCalVideoTranscription, + autoTranslate, + + // Seats + seatsEnabled, + seatsPerTimeSlot, + showAttendeeInfo, + showAvailabilityCount, + + // Recurring + recurringEnabled, + recurringInterval, + recurringFrequency, + recurringOccurrences, + }; + + // Build partial payload with only changed fields + const payload = buildPartialUpdatePayload(currentFormState, eventTypeData); + + if (Object.keys(payload).length === 0) { + Alert.alert("No Changes", "No changes were made to the event type."); + return; + } + await CalComAPIService.updateEventType(parseInt(id), payload); Alert.alert("Success", "Event type updated successfully"); - // Refresh event type data + // Refresh event type data to sync with server await fetchEventTypeData(); } } catch (error) { console.error("Failed to save event type:", error); - const action = id === "new" ? "create" : "update"; - Alert.alert("Error", `Failed to ${action} event type. Please try again.`); + const action = isCreateMode ? "create" : "update"; + showErrorAlert("Error", `Failed to ${action} event type. Please try again.`); } finally { setSaving(false); } @@ -1325,39 +1060,93 @@ export default function EventTypeDetail() { {/* Tabs */} - - - {tabs.map((tab) => ( - setActiveTab(tab.id)} - > - - - - {tab.label} - - - - ))} - - + + {tabs.map((tab) => ( + setActiveTab(tab.id)} + > + + + + {tab.label} + + + + ))} + + + ) : ( + + + {tabs.map((tab) => ( + setActiveTab(tab.id)} + > + + + + {tab.label} + + + + ))} + + + )} {/* Content */} )} @@ -1593,127 +1379,6 @@ export default function EventTypeDetail() { - {/* Location Dropdown Modal */} - setShowLocationDropdown(false)} - > - setShowLocationDropdown(false)} - > - - {conferencingLoading ? ( - - Loading locations... - - ) : ( - (() => { - const groups = getLocationOptions(); - if (groups.length === 0) { - return ( - - No locations available - - ); - } - return ( - - {groups.map((group) => ( - - {/* Section Header */} - - - {group.category} - - - {/* Section Options */} - {group.options.map((option) => ( - { - const newLocation = defaultLocations.find( - (loc) => loc.label === option.label - ); - setSelectedLocation(option.label); - setShowLocationDropdown(false); - // Clear input values that are not needed for the new location - if (!newLocation || newLocation.type !== "inPerson") { - setLocationAddress(""); - } - if (!newLocation || newLocation.type !== "link") { - setLocationLink(""); - } - if (!newLocation || newLocation.type !== "userPhone") { - setLocationPhone(""); - } - }} - > - - {option.iconUrl ? ( - - ) : ( - - - ? - - - )} - - {option.label} - - - {selectedLocation === option.label && ( - - )} - - ))} - - ))} - - ); - })() - )} - - - - {/* Before Event Buffer Dropdown Modal */} + {/* Recurring Frequency Dropdown Modal */} + setShowRecurringFrequencyDropdown(false)} + > + setShowRecurringFrequencyDropdown(false)} + > + + + Repeats every + + {(["weekly", "monthly", "yearly"] as const).map((option) => ( + { + setRecurringFrequency(option); + setShowRecurringFrequencyDropdown(false); + }} + > + + {option === "weekly" ? "week" : option === "monthly" ? "month" : "year"} + + {recurringFrequency === option && ( + + )} + + ))} + + + + {activeTab === "availability" && ( )} @@ -2085,33 +1791,98 @@ export default function EventTypeDetail() { setRecurringFrequency={setRecurringFrequency} recurringOccurrences={recurringOccurrences} setRecurringOccurrences={setRecurringOccurrences} + setShowFrequencyDropdown={setShowRecurringFrequencyDropdown} /> )} - {activeTab === "apps" && ( + {activeTab === "other" && ( - Connected Apps - - Manage app integrations for this event type. + Additional Settings + + Manage these settings on the web for full functionality. - - )} - {activeTab === "workflows" && ( - - Workflows - - Configure automated workflows and actions. - - - )} + + {/* Apps */} + { + if (id === "new") { + Alert.alert("Info", "Save the event type first to configure this setting."); + } else { + openInAppBrowser( + `https://app.cal.com/event-types/${id}?tabName=apps`, + "Apps settings" + ); + } + }} + className="flex-row items-center justify-between bg-white px-4 py-4 active:bg-[#F8F9FA]" + style={{ borderBottomWidth: 1, borderBottomColor: "#E5E5EA" }} + > + + + + + + Apps + Manage app integrations + + + + - {activeTab === "webhooks" && ( - - Webhooks - - Set up webhook endpoints for event notifications. - + {/* Workflows */} + { + if (id === "new") { + Alert.alert("Info", "Save the event type first to configure this setting."); + } else { + openInAppBrowser( + `https://app.cal.com/event-types/${id}?tabName=workflows`, + "Workflows settings" + ); + } + }} + className="flex-row items-center justify-between bg-white px-4 py-4 active:bg-[#F8F9FA]" + style={{ borderBottomWidth: 1, borderBottomColor: "#E5E5EA" }} + > + + + + + + Workflows + Configure automated actions + + + + + + {/* Webhooks */} + { + if (id === "new") { + Alert.alert("Info", "Save the event type first to configure this setting."); + } else { + openInAppBrowser( + `https://app.cal.com/event-types/${id}?tabName=webhooks`, + "Webhooks settings" + ); + } + }} + className="flex-row items-center justify-between bg-white px-4 py-4 active:bg-[#F8F9FA]" + > + + + + + + Webhooks + Set up event notifications + + + + + )} diff --git a/companion/app/event-type-detail/tabs/AdvancedTab.tsx b/companion/app/event-type-detail/tabs/AdvancedTab.tsx deleted file mode 100644 index 754d1ddf70..0000000000 --- a/companion/app/event-type-detail/tabs/AdvancedTab.tsx +++ /dev/null @@ -1,521 +0,0 @@ -import React from "react"; -import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native"; -import { Ionicons } from "@expo/vector-icons"; - -interface AdvancedTabProps { - // Calendar event name - calendarEventName: string; - setCalendarEventName: (value: string) => void; - - // Add to calendar email - addToCalendarEmail: string; - setAddToCalendarEmail: (value: string) => void; - - // Layout - selectedLayouts: string[]; - setSelectedLayouts: (layouts: string[]) => void; - defaultLayout: string; - setDefaultLayout: (layout: string) => void; - - // Confirmation settings - requiresConfirmation: boolean; - setRequiresConfirmation: (value: boolean) => void; - disableCancelling: boolean; - setDisableCancelling: (value: boolean) => void; - disableRescheduling: boolean; - setDisableRescheduling: (value: boolean) => void; - - // Additional settings - sendCalVideoTranscription: boolean; - setSendCalVideoTranscription: (value: boolean) => void; - autoTranslate: boolean; - setAutoTranslate: (value: boolean) => void; - requiresBookerEmailVerification: boolean; - setRequiresBookerEmailVerification: (value: boolean) => void; - hideCalendarNotes: boolean; - setHideCalendarNotes: (value: boolean) => void; - hideCalendarEventDetails: boolean; - setHideCalendarEventDetails: (value: boolean) => void; - hideOrganizerEmail: boolean; - setHideOrganizerEmail: (value: boolean) => void; - lockTimezone: boolean; - setLockTimezone: (value: boolean) => void; - allowReschedulingPastEvents: boolean; - setAllowReschedulingPastEvents: (value: boolean) => void; - allowBookingThroughRescheduleLink: boolean; - setAllowBookingThroughRescheduleLink: (value: boolean) => void; - - // Redirect on booking - successRedirectUrl: string; - setSuccessRedirectUrl: (value: string) => void; - forwardParamsSuccessRedirect: boolean; - setForwardParamsSuccessRedirect: (value: boolean) => void; - - // Custom reply-to email - customReplyToEmail: string; - setCustomReplyToEmail: (value: string) => void; - - // Event type colors - eventTypeColorLight: string; - setEventTypeColorLight: (value: string) => void; - eventTypeColorDark: string; - setEventTypeColorDark: (value: string) => void; -} - -export function AdvancedTab(props: AdvancedTabProps) { - const layoutOptions = [ - { id: "MONTH_VIEW", label: "Month", icon: "calendar-outline" }, - { id: "WEEK_VIEW", label: "Weekly", icon: "calendar-outline" }, - { id: "COLUMN_VIEW", label: "Column", icon: "list-outline" }, - ]; - - return ( - - {/* Calendar Event Name Card */} - - Calendar event name - - - Use variables like {"{"}Scheduler{"}"} for booker name, {"{"}Organizer{"}"} for your name - - - - {/* Add to Calendar Card */} - - Add to calendar - - We'll display this email address as the organizer, and send confirmation emails here. - - - - - {/* Layout Card */} - - Layout - - You can select multiple and your bookers can switch views. - - - {/* Layout Options */} - - {layoutOptions.map((layout) => ( - { - if (props.selectedLayouts.includes(layout.id)) { - // Don't allow deselecting if it's the only one - if (props.selectedLayouts.length > 1) { - props.setSelectedLayouts(props.selectedLayouts.filter((l) => l !== layout.id)); - // If removing the default, set a new default - if (props.defaultLayout === layout.id) { - const remaining = props.selectedLayouts.filter((l) => l !== layout.id); - props.setDefaultLayout(remaining[0]); - } - } - } else { - props.setSelectedLayouts([...props.selectedLayouts, layout.id]); - } - }} - > - - - {layout.label} - - {props.selectedLayouts.includes(layout.id) && ( - - )} - - ))} - - - {/* Default View */} - {props.selectedLayouts.length > 1 && ( - - Default view - - {props.selectedLayouts.map((layoutId) => { - const layout = layoutOptions.find((l) => l.id === layoutId); - if (!layout) return null; - return ( - props.setDefaultLayout(layout.id)} - > - {layout.label} - {props.defaultLayout === layout.id && ( - - )} - - ); - })} - - - )} - - - {/* Booking Questions Card */} - - Booking questions - - Customize the questions asked on the booking page. - - - Alert.alert("Coming Soon", "Booking questions customization will be available soon.") - } - > - - Manage booking questions - - - - {/* Requires confirmation */} - - - - Requires confirmation - - The booking needs to be manually confirmed before it is pushed to your calendar - - - - - - - {/* Disable Cancelling */} - - - - Disable Cancelling - - Guests can no longer cancel the event with calendar invite or email - - - - - - - {/* Disable Rescheduling */} - - - - Disable Rescheduling - - Guests can no longer reschedule the event with calendar invite or email - - - - - - - {/* Send Cal Video Transcription Emails */} - - - - - Send Cal Video Transcription Emails - - - Send emails with the transcription of the Cal Video after the meeting ends - - - - - - - {/* Auto translate */} - - - - - Auto translate title and description - - - Automatically translate titles and descriptions to the visitor's browser language - using AI - - - - - - - {/* Requires booker email verification */} - - - - - Requires booker email verification - - - To ensure booker's email verification before scheduling events - - - - - - - {/* Hide notes in calendar */} - - - - Hide notes in calendar - - For privacy reasons, additional inputs and notes will be hidden in the calendar entry - - - - - - - {/* Hide calendar event details */} - - - - - Hide calendar event details on shared calendars - - - When a calendar is shared, events are visible but details are hidden from those - without write access - - - - - - - {/* Hide organizer's email */} - - - - Hide organizer's email - - Hide organizer's email address from the booking screen, email notifications, and - calendar events - - - - - - - {/* Lock timezone */} - - - - - Lock timezone on booking page - - - To lock the timezone on booking page, useful for in-person events - - - - - - - {/* Allow rescheduling past events */} - - - - - Allow rescheduling past events - - - Enabling this option allows for past events to be rescheduled - - - - - - - {/* Allow booking through reschedule link */} - - - - - Allow booking through reschedule link - - - When enabled, users will be able to create a new booking when trying to reschedule a - cancelled booking - - - - - - - {/* Redirect on booking Card */} - - Redirect on booking - - Redirect to a custom URL after a successful booking - - - - Forward query parameters - - - - - {/* Custom Reply-To Email Card */} - - Custom 'Reply-To' email - - Use a different email address as the replyTo for confirmation emails instead of the - organizer's email - - - - - {/* Event Type Color Card */} - - Event type color - - This is only used for event type & booking differentiation within the app. It is not - displayed to bookers. - - - - Light theme color - - - - - - - Dark theme color - - - - - - - - - ); -} diff --git a/companion/app/event-type-detail/tabs/RecurringTab.tsx b/companion/app/event-type-detail/tabs/RecurringTab.tsx deleted file mode 100644 index d779fdef14..0000000000 --- a/companion/app/event-type-detail/tabs/RecurringTab.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from "react"; -import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native"; -import { Ionicons } from "@expo/vector-icons"; - -interface RecurringTabProps { - recurringEnabled: boolean; - setRecurringEnabled: (value: boolean) => void; - recurringInterval: string; - setRecurringInterval: (value: string) => void; - recurringFrequency: "daily" | "weekly" | "monthly" | "yearly"; - setRecurringFrequency: (value: "daily" | "weekly" | "monthly" | "yearly") => void; - recurringOccurrences: string; - setRecurringOccurrences: (value: string) => void; -} - -export function RecurringTab({ - recurringEnabled, - setRecurringEnabled, - recurringInterval, - setRecurringInterval, - recurringFrequency, - setRecurringFrequency, - recurringOccurrences, - setRecurringOccurrences, -}: RecurringTabProps) { - return ( - - {/* Recurring Event Toggle Card */} - - - - Recurring event - - Set up this event type to repeat at regular intervals - - - - - - - {/* Recurring Configuration Card - shown when enabled */} - {recurringEnabled && ( - - Recurrence pattern - - {/* Repeats Every */} - - Repeats every - - { - const numericValue = text.replace(/[^0-9]/g, ""); - // Don't allow empty or 0 values; fall back to 1 so users can keep editing - if (numericValue === "" || numericValue === "0") { - setRecurringInterval("1"); - return; - } - const num = parseInt(numericValue); - if (num >= 1 && num <= 20) { - setRecurringInterval(numericValue); - } - }} - placeholder="1" - placeholderTextColor="#8E8E93" - keyboardType="numeric" - /> - { - Alert.alert("Select Frequency", "Choose how often this event repeats", [ - { - text: "Daily", - onPress: () => setRecurringFrequency("daily"), - }, - { - text: "Weekly", - onPress: () => setRecurringFrequency("weekly"), - }, - { - text: "Monthly", - onPress: () => setRecurringFrequency("monthly"), - }, - { - text: "Yearly", - onPress: () => setRecurringFrequency("yearly"), - }, - { text: "Cancel", style: "cancel" }, - ]); - }} - > - {recurringFrequency} - - - - - - {/* Maximum Occurrences */} - - Maximum number of events - - { - const numericValue = text.replace(/[^0-9]/g, ""); - // Don't allow empty or 0 values; fall back to 1 so users can keep editing - if (numericValue === "" || numericValue === "0") { - setRecurringOccurrences("1"); - return; - } - const num = parseInt(numericValue); - if (num >= 1) { - setRecurringOccurrences(numericValue); - } - }} - placeholder="12" - placeholderTextColor="#8E8E93" - keyboardType="numeric" - /> - occurrences - - - The booking will create {recurringOccurrences} events that repeat {recurringFrequency} - - - - )} - - ); -} diff --git a/companion/app/event-type-detail/utils.ts b/companion/app/event-type-detail/utils.ts deleted file mode 100644 index c6682935f6..0000000000 --- a/companion/app/event-type-detail/utils.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Utility functions for Event Type Detail - -export const formatDuration = (minutes: string) => { - const mins = parseInt(minutes) || 0; - if (mins < 60) return `${mins}m`; - const hours = Math.floor(mins / 60); - const remainingMins = mins % 60; - return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`; -}; - -export const truncateTitle = (text: string, maxLength: number = 20) => { - return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; -}; - -export const formatAppIdToDisplayName = (appId: string): string => { - // Convert appId like "google-meet" to "Google Meet" - return appId - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -}; - -export const displayNameToLocationValue = ( - displayName: string, - defaultLocations: Array<{ label: string; type: string }> -): { - type: string; - integration?: string; - address?: string; - link?: string; - phone?: string; - public?: boolean; -} | null => { - // First check if it's a default location - const defaultLocation = defaultLocations.find((loc) => loc.label === displayName); - if (defaultLocation) { - // Map internal location types to API location types - switch (defaultLocation.type) { - case "attendeeInPerson": - return { type: "attendeeAddress" }; - case "inPerson": - return { type: "address", address: "", public: true }; - case "link": - return { type: "link", link: "", public: true }; - case "phone": - return { type: "attendeePhone" }; - case "userPhone": - return { type: "phone", phone: "", public: true }; - default: - return null; - } - } - - // Check if it's a conferencing app (formatted display name) - // e.g., "Google Meet", "Zoom", etc. - const appId = displayName.toLowerCase().replace(/\s+/g, "-"); - return { type: "integration", integration: appId }; -}; diff --git a/companion/assets/favicon-new3.png b/companion/assets/favicon-new3.png new file mode 100644 index 0000000000..379c5a5132 Binary files /dev/null and b/companion/assets/favicon-new3.png differ diff --git a/companion/components/BookingActionsModal.tsx b/companion/components/BookingActionsModal.tsx new file mode 100644 index 0000000000..a51e4a0009 --- /dev/null +++ b/companion/components/BookingActionsModal.tsx @@ -0,0 +1,288 @@ +/** + * BookingActionsModal Component + * + * A reusable modal component for booking actions that can be used in both + * the bookings list screen and the booking detail screen. + */ + +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { View, Text, TouchableOpacity, Alert } from "react-native"; + +import { FullScreenModal } from "./FullScreenModal"; +import type { Booking } from "../services/calcom"; + +export interface BookingActionsModalProps { + visible: boolean; + onClose: () => void; + booking: Booking | null; + hasLocationUrl?: boolean; + isUpcoming?: boolean; // When true, disables "after event" actions (View Recordings, Session Details, Mark No-Show) + isPast?: boolean; // When true, disables "edit event" actions (Reschedule, Edit Location, Add Guests) and Cancel Booking + isCancelled?: boolean; // When true, only Report Booking and Mark as No-Show are enabled + isUnconfirmed?: boolean; // When true, disables Reschedule, Edit Location, Add Guests + onReschedule: () => void; + onEditLocation: () => void; + onAddGuests: () => void; + onViewRecordings: () => void; + onMeetingSessionDetails: () => void; + onMarkNoShow: () => void; + onReportBooking: () => void; + onCancelBooking: () => void; +} + +// Style constants for easy customization +const ICON_SIZE = 16; +const ICON_COLOR = "#6B7280"; +const ICON_COLOR_DANGER = "#800000"; // Maroon +const DISABLED_ICON_COLOR = "#D1D5DB"; +const TEXT_CLASS = "text-lg"; +const TEXT_COLOR_CLASS = "text-gray-900"; +const TEXT_COLOR_DANGER_CLASS = "text-[#800000]"; // Maroon +const DISABLED_TEXT_COLOR_CLASS = "text-gray-300"; + +export function BookingActionsModal({ + visible, + onClose, + booking, + hasLocationUrl = false, + isUpcoming = false, + isPast = false, + isCancelled = false, + isUnconfirmed = false, + onReschedule, + onEditLocation, + onAddGuests, + onViewRecordings, + onMeetingSessionDetails, + onMarkNoShow, + onReportBooking, + onCancelBooking, +}: BookingActionsModalProps) { + if (!booking) return null; + + // For cancelled bookings, only Report Booking and Mark as No-Show are enabled + // "After event" actions (except Mark as No-Show) are disabled for upcoming, cancelled, or unconfirmed bookings + const afterEventActionsDisabled = isUpcoming || isCancelled || isUnconfirmed; + // "Edit event" actions (Reschedule, Edit Location, Add Guests) are disabled for past, cancelled, or unconfirmed bookings + const editEventActionsDisabled = isPast || isCancelled || isUnconfirmed; + // Cancel booking is disabled for past or cancelled bookings (but NOT for unconfirmed - user can still cancel/decline) + const cancelBookingDisabled = isPast || isCancelled; + + return ( + + + e.stopPropagation()} + > + {/* Actions List */} + + {/* Edit event label */} + + Edit event + + + {/* Reschedule Booking */} + { + if (editEventActionsDisabled) return; + onClose(); + onReschedule(); + }} + disabled={editEventActionsDisabled} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + + Reschedule Booking + + + + {/* Edit Location */} + { + if (editEventActionsDisabled) return; + onClose(); + onEditLocation(); + }} + disabled={editEventActionsDisabled} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + + Edit Location + + + + {/* Add Guests */} + { + if (editEventActionsDisabled) return; + onClose(); + onAddGuests(); + }} + disabled={editEventActionsDisabled} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + + Add Guests + + + + {/* Separator */} + + + {/* After event label */} + + After event + + + {/* View Recordings */} + {hasLocationUrl && ( + { + if (afterEventActionsDisabled) return; + onClose(); + onViewRecordings(); + }} + disabled={afterEventActionsDisabled} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + + View Recordings + + + )} + + {/* Meeting Session Details */} + {hasLocationUrl && ( + { + if (afterEventActionsDisabled) return; + onClose(); + onMeetingSessionDetails(); + }} + disabled={afterEventActionsDisabled} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + + Meeting Session Details + + + )} + + {/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */} + { + if (isUpcoming || isUnconfirmed) return; + onClose(); + onMarkNoShow(); + }} + disabled={isUpcoming || isUnconfirmed} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + + Mark as No-Show + + + + {/* Separator */} + + + {/* Report Booking */} + { + onClose(); + onReportBooking(); + }} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + + Report Booking + + + + {/* Separator */} + + + {/* Cancel Booking */} + { + if (cancelBookingDisabled) return; + onClose(); + onCancelBooking(); + }} + disabled={cancelBookingDisabled} + className="flex-row items-center p-2 hover:bg-gray-50 md:p-4" + > + + + Cancel Event + + + + + {/* Cancel button */} + + + Cancel + + + + + + ); +} diff --git a/companion/components/CacheStatusIndicator.tsx b/companion/components/CacheStatusIndicator.tsx new file mode 100644 index 0000000000..d27b793d41 --- /dev/null +++ b/companion/components/CacheStatusIndicator.tsx @@ -0,0 +1,146 @@ +/** + * CacheStatusIndicator Component + * + * A subtle, non-intrusive component that displays cache status information: + * - "Last updated: X minutes ago" when online + * - "Offline - showing cached data" when offline + * + * This component is designed to be placed in screen headers or footers + * to provide users with transparency about data freshness. + */ + +import React from "react"; +import { View, Text, StyleSheet } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useQueryContext } from "../contexts/QueryContext"; + +/** + * Props for the CacheStatusIndicator component + */ +interface CacheStatusIndicatorProps { + /** Timestamp when the data was last fetched (from query.dataUpdatedAt) */ + dataUpdatedAt?: number; + /** Whether data is currently being fetched */ + isFetching?: boolean; + /** Optional custom style */ + style?: object; + /** Show compact version (icon only when online) */ + compact?: boolean; +} + +/** + * Format the time difference in a human-readable way + */ +const formatTimeAgo = (timestamp: number): string => { + const now = Date.now(); + const diffMs = now - timestamp; + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + + if (diffSeconds < 10) { + return "Just now"; + } + if (diffSeconds < 60) { + return `${diffSeconds}s ago`; + } + if (diffMinutes < 60) { + return `${diffMinutes}m ago`; + } + if (diffHours < 24) { + return `${diffHours}h ago`; + } + return "Over a day ago"; +}; + +/** + * CacheStatusIndicator component + * + * @example + * ```tsx + * const { dataUpdatedAt, isFetching } = useBookings(); + * + * + * ``` + */ +export function CacheStatusIndicator({ + dataUpdatedAt, + isFetching = false, + style, + compact = false, +}: CacheStatusIndicatorProps) { + const { isOnline } = useQueryContext(); + + // Don't show anything if no data has been fetched yet + if (!dataUpdatedAt && !isFetching && isOnline) { + return null; + } + + // Offline state + if (!isOnline) { + return ( + + + Offline - showing cached data + + ); + } + + // Fetching state + if (isFetching) { + return ( + + + {!compact && Updating...} + + ); + } + + // Normal state with last updated time + if (dataUpdatedAt) { + if (compact) { + return ( + + + + ); + } + + return ( + + + Updated {formatTimeAgo(dataUpdatedAt)} + + ); + } + + return null; +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingVertical: 4, + paddingHorizontal: 8, + }, + offlineContainer: { + backgroundColor: "rgba(255, 149, 0, 0.1)", + borderRadius: 4, + }, + text: { + fontSize: 11, + color: "#8E8E93", + }, + offlineText: { + fontSize: 11, + color: "#FF9500", + fontWeight: "500", + }, +}); + +export default CacheStatusIndicator; diff --git a/companion/components/EmptyScreen.tsx b/companion/components/EmptyScreen.tsx new file mode 100644 index 0000000000..710cd27b55 --- /dev/null +++ b/companion/components/EmptyScreen.tsx @@ -0,0 +1,51 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { View, Text, TouchableOpacity } from "react-native"; + +type IoniconName = keyof typeof Ionicons.glyphMap; + +interface EmptyScreenProps { + icon: IoniconName; + headline: string; + description: string; + buttonText?: string; + onButtonPress?: () => void; + className?: string; +} + +export function EmptyScreen({ + icon, + headline, + description, + buttonText, + onButtonPress, + className, +}: EmptyScreenProps) { + return ( + + + + + + + {headline} + + + {description} + + + {buttonText && onButtonPress && ( + + + {buttonText} + + )} + + + ); +} diff --git a/companion/components/Header.tsx b/companion/components/Header.tsx index 237e8d67e9..c8cf54bbe5 100644 --- a/companion/components/Header.tsx +++ b/companion/components/Header.tsx @@ -7,16 +7,16 @@ import { TouchableOpacity, Image, ActivityIndicator, - Modal, - Alert, - Linking, Platform, ActionSheetIOS, + Alert, } from "react-native"; +import * as Clipboard from "expo-clipboard"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { CalComAPIService, UserProfile } from "../services/calcom"; import { CalComLogo } from "./CalComLogo"; import { FullScreenModal } from "./FullScreenModal"; +import { openInAppBrowser } from "../utils/browser"; export function Header() { const router = useRouter(); @@ -40,14 +40,41 @@ export function Header() { } }; + // Build public page URL + const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null; + + const handleViewPublicPage = () => { + if (publicPageUrl) { + openInAppBrowser(publicPageUrl, "Public page"); + } + }; + + const handleCopyPublicPageLink = async () => { + if (!publicPageUrl) return; + try { + await Clipboard.setStringAsync(publicPageUrl); + Alert.alert("Link Copied!", "Your public page link has been copied to clipboard."); + } catch (error) { + console.error("Failed to copy public page link:", error); + Alert.alert("Error", "Failed to copy link. Please try again."); + } + }; + const handleProfile = () => { if (Platform.OS === "ios") { - const options = ["Cancel", "My Profile", "My Settings", "Out of Office", "Help", "Sign Out"]; + const options = [ + "Cancel", + "My Profile", + "My Settings", + "Out of Office", + "View public page", + "Copy public page link", + "Help", + ]; ActionSheetIOS.showActionSheetWithOptions( { options, - destructiveButtonIndex: 6, // Sign Out cancelButtonIndex: 0, title: userProfile?.name || "Profile Menu", }, @@ -62,11 +89,14 @@ export function Header() { case 3: // Out of Office handleMenuOption("outOfOffice"); break; - case 4: // Support - handleMenuOption("help"); + case 4: // View public page + handleViewPublicPage(); break; - case 5: // Sign Out - handleMenuOption("signOut"); + case 5: // Copy public page link + handleCopyPublicPageLink(); + break; + case 6: // Help + handleMenuOption("help"); break; } } @@ -76,55 +106,28 @@ export function Header() { } }; - const openExternalLink = async (url: string, fallbackMessage: string) => { - try { - const supported = await Linking.canOpenURL(url); - if (supported) { - await Linking.openURL(url); - } else { - Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`); - } - } catch (error) { - console.error(`Failed to open ${url}:`, error); - Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`); - } - }; - const handleMenuOption = (option: string) => { if (Platform.OS !== "ios") { setShowProfileModal(false); } switch (option) { case "profile": - openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page"); + openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"); break; case "settings": - openExternalLink("https://app.cal.com/settings/my-account", "Settings page"); + openInAppBrowser("https://app.cal.com/settings/my-account", "Settings page"); break; case "outOfOffice": - openExternalLink( + openInAppBrowser( "https://app.cal.com/settings/my-account/out-of-office", "Out of Office page" ); break; case "roadmap": - openExternalLink("https://cal.com/roadmap", "Roadmap"); + openInAppBrowser("https://cal.com/roadmap", "Roadmap"); break; case "help": - openExternalLink("https://cal.com/help", "Help page"); - break; - case "signOut": - Alert.alert("Sign Out", "Are you sure you want to sign out?", [ - { text: "Cancel", style: "cancel" }, - { - text: "Sign Out", - style: "destructive", - onPress: () => { - // TODO: Implement sign out - console.log("Sign Out pressed"); - }, - }, - ]); + openInAppBrowser("https://cal.com/help", "Help page"); break; } }; @@ -250,6 +253,37 @@ export function Header() { + {/* View public page */} + { + setShowProfileModal(false); + handleViewPublicPage(); + }} + > + + + View public page + + + + + {/* Copy public page link */} + { + setShowProfileModal(false); + handleCopyPublicPageLink(); + }} + > + + + Copy public page link + + + + + handleMenuOption("roadmap")} @@ -271,16 +305,6 @@ export function Header() { - - - - handleMenuOption("signOut")} - > - - Sign Out - {/* Cancel button */} diff --git a/companion/components/LoadingSpinner.tsx b/companion/components/LoadingSpinner.tsx new file mode 100644 index 0000000000..2d89e169bf --- /dev/null +++ b/companion/components/LoadingSpinner.tsx @@ -0,0 +1,71 @@ +/** + * LoadingSpinner Component + * + * A stylish loading spinner with iOS glass effect support when available. + * Falls back to a nice styled container on other platforms. + */ + +import React from "react"; +import { View, ActivityIndicator, Platform, StyleSheet } from "react-native"; +import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; + +interface LoadingSpinnerProps { + /** Size of the spinner - defaults to large */ + size?: "small" | "large"; + /** Color of the spinner - defaults to system color */ + color?: string; + /** Whether to show the container background */ + showBackground?: boolean; +} + +export function LoadingSpinner({ + size = "large", + color, + showBackground = true, +}: LoadingSpinnerProps) { + const supportsGlass = isLiquidGlassAvailable(); + + // Use glass effect on supported iOS devices + if (supportsGlass && Platform.OS === "ios") { + return ( + + + + ); + } + + // Styled container for other platforms + if (showBackground) { + return ( + + + + ); + } + + // Simple spinner without background + return ; +} + +const styles = StyleSheet.create({ + glassContainer: { + alignItems: "center", + justifyContent: "center", + padding: 24, + borderRadius: 20, + }, + styledContainer: { + alignItems: "center", + justifyContent: "center", + padding: 24, + borderRadius: 20, + backgroundColor: "rgba(255, 255, 255, 0.9)", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + }, +}); + +export default LoadingSpinner; diff --git a/companion/components/LocationsList.tsx b/companion/components/LocationsList.tsx new file mode 100644 index 0000000000..c49616b8ce --- /dev/null +++ b/companion/components/LocationsList.tsx @@ -0,0 +1,274 @@ +/** + * LocationsList Component + * Reusable component for displaying and managing multiple event type locations + */ + +import React, { useState } from "react"; +import { + View, + Text, + TouchableOpacity, + TextInput, + Modal, + ScrollView, + Platform, + ActionSheetIOS, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; + +import { SvgImage } from "./SvgImage"; +import { LocationItem, LocationOptionGroup } from "../types/locations"; +import { + locationRequiresInput, + getLocationInputPlaceholder, + getLocationInputLabel, + createLocationItemFromOption, +} from "../utils/locationHelpers"; + +interface LocationsListProps { + /** Array of current locations */ + locations: LocationItem[]; + /** Callback when a location is added */ + onAdd: (location: LocationItem) => void; + /** Callback when a location is removed */ + onRemove: (locationId: string) => void; + /** Callback when a location is updated (for input fields) */ + onUpdate: (locationId: string, updates: Partial) => void; + /** Available location options grouped by category */ + locationOptions: LocationOptionGroup[]; + /** Whether the component is disabled */ + disabled?: boolean; + /** Whether locations are loading */ + loading?: boolean; +} + +function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string): boolean { + return locations.some((loc) => { + if (loc.type === "integration" && optionValue.startsWith("integrations:")) { + return loc.integration === optionValue.replace("integrations:", ""); + } + if (["address", "link", "phone"].includes(loc.type)) { + return false; + } + return loc.type === optionValue; + }); +} + +export const LocationsList: React.FC = ({ + locations, + onAdd, + onRemove, + onUpdate, + locationOptions, + disabled = false, + loading = false, +}) => { + const [showAddModal, setShowAddModal] = useState(false); + + const handleAddLocation = () => { + if (Platform.OS === "ios") { + const allOptions: Array<{ label: string; value: string }> = []; + locationOptions.forEach((group) => { + group.options.forEach((option) => { + if (!isLocationAlreadyAdded(locations, option.value)) { + allOptions.push({ label: option.label, value: option.value }); + } + }); + }); + + const options = [...allOptions.map((o) => o.label), "Cancel"]; + + ActionSheetIOS.showActionSheetWithOptions( + { + options, + cancelButtonIndex: options.length - 1, + title: "Add Location", + }, + (buttonIndex) => { + if (buttonIndex !== options.length - 1 && buttonIndex < allOptions.length) { + const selected = allOptions[buttonIndex]; + const newLocation = createLocationItemFromOption(selected.value, selected.label); + onAdd(newLocation); + } + } + ); + } else { + setShowAddModal(true); + } + }; + + const handleSelectOption = (optionValue: string, optionLabel: string) => { + const newLocation = createLocationItemFromOption(optionValue, optionLabel); + onAdd(newLocation); + setShowAddModal(false); + }; + + const renderLocationIcon = (location: LocationItem) => { + if (location.iconUrl) { + return ; + } + return ( + + + + ); + }; + + const renderLocationInput = (location: LocationItem) => { + if (!locationRequiresInput(location.type)) { + return null; + } + + const placeholder = getLocationInputPlaceholder(location.type); + const label = getLocationInputLabel(location.type); + + let value = ""; + let fieldKey: "address" | "link" | "phone" = "address"; + + switch (location.type) { + case "address": + value = location.address || ""; + fieldKey = "address"; + break; + case "link": + value = location.link || ""; + fieldKey = "link"; + break; + case "phone": + value = location.phone || ""; + fieldKey = "phone"; + break; + } + + return ( + + {label} + onUpdate(location.id, { [fieldKey]: text })} + editable={!disabled} + keyboardType={location.type === "phone" ? "phone-pad" : "default"} + autoCapitalize={location.type === "link" ? "none" : "sentences"} + /> + + ); + }; + + return ( + + {/* Locations List */} + {locations.length > 0 ? ( + + {locations.map((location, index) => ( + + + + {renderLocationIcon(location)} + + {location.displayName} + + + {!disabled && ( + onRemove(location.id)} + className="ml-2 p-1" + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + )} + + {renderLocationInput(location)} + + ))} + + ) : ( + + No locations added yet + + )} + + {/* Add Location Button */} + + {loading ? ( + Loading options... + ) : ( + <> + + Add Location + + )} + + + {/* Add Location Modal (for non-iOS) */} + setShowAddModal(false)} + > + + + {/* Header */} + + Add Location + setShowAddModal(false)} className="p-1"> + + + + + {/* Options List */} + + {locationOptions.map((group, groupIndex) => ( + 0 ? "mt-4" : ""}> + + {group.category} + + {group.options.map((option) => { + const alreadyAdded = isLocationAlreadyAdded(locations, option.value); + + return ( + handleSelectOption(option.value, option.label)} + disabled={alreadyAdded} + className={`flex-row items-center rounded-lg px-2 py-3 ${ + alreadyAdded ? "opacity-40" : "active:bg-gray-100" + }`} + > + {option.iconUrl ? ( + + ) : ( + + + + )} + {option.label} + {alreadyAdded && } + + ); + })} + + ))} + {/* Bottom padding for safe area */} + + + + + + + ); +}; diff --git a/companion/components/LoginScreen.tsx b/companion/components/LoginScreen.tsx index d20ea5e0f2..a5f765af15 100644 --- a/companion/components/LoginScreen.tsx +++ b/companion/components/LoginScreen.tsx @@ -1,46 +1,75 @@ import React from "react"; -import { View, Text, TouchableOpacity, Alert, ActivityIndicator } from "react-native"; +import { View, Text, TouchableOpacity } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAuth } from "../contexts/AuthContext"; +import { CalComLogo } from "./CalComLogo"; +import { showErrorAlert } from "../utils/alerts"; +import { openInAppBrowser } from "../utils/browser"; export function LoginScreen() { const { loginWithOAuth, loading } = useAuth(); + const insets = useSafeAreaInsets(); const handleOAuthLogin = async () => { try { await loginWithOAuth(); } catch (error) { console.error("OAuth login error:", error); - Alert.alert( + showErrorAlert( "Login Failed", error instanceof Error ? error.message - : "Failed to login with OAuth. Please check your configuration and try again.", - [{ text: "OK" }] + : "Failed to login with OAuth. Please check your configuration and try again." ); } }; + const handleSignUp = async () => { + await openInAppBrowser("https://app.cal.com/signup", "Sign up page"); + }; + return ( - - - - Welcome to Cal.com Companion - - - Sign in to manage your bookings and event types - + + {/* Logo centered in the middle */} + + + + + {/* Bottom section with button */} + + {/* Primary CTA button */} + + Continue with Cal.com + + + {/* Sign up link */} + + + + Don't have an account? Sign up + + + + - - {loading ? : null} - - {loading ? "Signing in..." : "Sign in with Cal.com"} - - ); } diff --git a/companion/components/NetworkStatusBanner.tsx b/companion/components/NetworkStatusBanner.tsx new file mode 100644 index 0000000000..a750baae2a --- /dev/null +++ b/companion/components/NetworkStatusBanner.tsx @@ -0,0 +1,110 @@ +/** + * NetworkStatusBanner Component + * + * Shows a minimal, classy popup when the device is offline. + * - Shows popup when going offline + * - Auto-dismisses when internet comes back + * - Shows again on next disconnect + */ + +import React, { useEffect, useState, useRef } from "react"; +import { View, Text, TouchableOpacity, Modal, Animated } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import NetInfo, { NetInfoState } from "@react-native-community/netinfo"; + +export function NetworkStatusBanner() { + const [showModal, setShowModal] = useState(false); + const fadeAnim = useRef(new Animated.Value(0)).current; + + // Simple refs to track state + const previousOfflineRef = useRef(null); + const userDismissedRef = useRef(false); + + const checkIfOffline = (state: NetInfoState): boolean => { + if (state.isConnected === false) return true; + if (state.isInternetReachable === false) return true; + return false; + }; + + useEffect(() => { + const handleNetworkChange = (state: NetInfoState) => { + const currentlyOffline = checkIfOffline(state); + const wasOffline = previousOfflineRef.current; + + // Transition: Online → Offline (only show if user hasn't dismissed) + if (currentlyOffline && wasOffline === false && !userDismissedRef.current) { + setShowModal(true); + } + + // Transition: Offline → Online + if (!currentlyOffline && wasOffline === true) { + setShowModal(false); // Auto-dismiss + userDismissedRef.current = false; // Reset for next offline event + } + + previousOfflineRef.current = currentlyOffline; + }; + + // Get initial state + NetInfo.fetch().then((state) => { + const offline = checkIfOffline(state); + previousOfflineRef.current = offline; + if (offline) { + setShowModal(true); + } + }); + + // Listen for changes + const unsubscribe = NetInfo.addEventListener(handleNetworkChange); + return () => unsubscribe(); + }, []); + + useEffect(() => { + if (showModal) { + fadeAnim.setValue(0); + Animated.timing(fadeAnim, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }).start(); + } + }, [showModal, fadeAnim]); + + const handleDismiss = () => { + userDismissedRef.current = true; + Animated.timing(fadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }).start(() => { + setShowModal(false); + }); + }; + + if (!showModal) return null; + + return ( + + + + + You're offline + + The internet left the chat. Don't panic! You can still browse around. + + + Cool, got it + + + + + ); +} + +export default NetworkStatusBanner; diff --git a/companion/app/event-type-detail/constants.ts b/companion/components/event-type-detail/constants.ts similarity index 100% rename from companion/app/event-type-detail/constants.ts rename to companion/components/event-type-detail/constants.ts diff --git a/companion/components/event-type-detail/tabs/AdvancedTab.tsx b/companion/components/event-type-detail/tabs/AdvancedTab.tsx new file mode 100644 index 0000000000..a51a430f70 --- /dev/null +++ b/companion/components/event-type-detail/tabs/AdvancedTab.tsx @@ -0,0 +1,514 @@ +import React from "react"; +import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; + +import { openInAppBrowser } from "../../../utils/browser"; + +interface ConfigureOnWebCardProps { + title: string; + description: string; + eventTypeId: string; + browserTitle: string; +} + +function ConfigureOnWebCard({ + title, + description, + eventTypeId, + browserTitle, +}: ConfigureOnWebCardProps) { + return ( + + {title} + {description} + { + if (eventTypeId && eventTypeId !== "new") { + openInAppBrowser( + `https://app.cal.com/event-types/${eventTypeId}?tabName=advanced`, + browserTitle + ); + } else { + Alert.alert("Info", "Save the event type first to configure this setting."); + } + }} + > + + Configure on Web + + + ); +} + +interface AdvancedTabProps { + requiresConfirmation: boolean; + setRequiresConfirmation: (value: boolean) => void; + autoTranslate: boolean; + setAutoTranslate: (value: boolean) => void; + requiresBookerEmailVerification: boolean; + setRequiresBookerEmailVerification: (value: boolean) => void; + hideCalendarNotes: boolean; + setHideCalendarNotes: (value: boolean) => void; + hideCalendarEventDetails: boolean; + setHideCalendarEventDetails: (value: boolean) => void; + hideOrganizerEmail: boolean; + setHideOrganizerEmail: (value: boolean) => void; + lockTimezone: boolean; + setLockTimezone: (value: boolean) => void; + lockedTimezone: string; + setLockedTimezone: (value: string) => void; + allowReschedulingPastEvents: boolean; + setAllowReschedulingPastEvents: (value: boolean) => void; + allowBookingThroughRescheduleLink: boolean; + setAllowBookingThroughRescheduleLink: (value: boolean) => void; + successRedirectUrl: string; + setSuccessRedirectUrl: (value: string) => void; + forwardParamsSuccessRedirect: boolean; + setForwardParamsSuccessRedirect: (value: boolean) => void; + customReplyToEmail: string; + setCustomReplyToEmail: (value: string) => void; + eventTypeColorLight: string; + setEventTypeColorLight: (value: string) => void; + eventTypeColorDark: string; + setEventTypeColorDark: (value: string) => void; + seatsEnabled: boolean; + setSeatsEnabled: (value: boolean) => void; + seatsPerTimeSlot: string; + setSeatsPerTimeSlot: (value: string) => void; + showAttendeeInfo: boolean; + setShowAttendeeInfo: (value: boolean) => void; + showAvailabilityCount: boolean; + setShowAvailabilityCount: (value: boolean) => void; + eventTypeId: string; +} + +export function AdvancedTab(props: AdvancedTabProps) { + return ( + + + + + Requires confirmation + + The booking needs to be manually confirmed before it is pushed to your calendar and a + confirmation is sent. + + + + + + + + + + + + + + + + + Auto translate title and description + + + Automatically translate titles and descriptions to the visitor's browser language + using AI. + + + + + + + + + + + + + Requires booker email verification + + + To ensure booker's email verification before scheduling events. + + + + + + + + + + Hide notes in calendar + + For privacy reasons, additional inputs and notes will be hidden in the calendar entry. + They will still be sent to your email. + + + + + + + + + + + Hide calendar event details on shared calendars + + + When a calendar is shared, events are visible to readers but their details are hidden + from those without write access. + + + + + + + + Redirect on booking + + Redirect to a custom URL after a successful booking. + + + + + Forward parameters such as ?email=...&name=... + + + + {props.successRedirectUrl ? ( + + Adding a redirect will disable the success page. Make sure to mention "Booking + Confirmed" on your custom success page. + + ) : null} + + + + Private Links + + Generate private URLs without exposing the username, with configurable expiry and usage + limits. + + { + if (props.eventTypeId && props.eventTypeId !== "new") { + openInAppBrowser( + `https://app.cal.com/event-types/${props.eventTypeId}?tabName=advanced`, + "Private Links" + ); + } else { + Alert.alert("Info", "Save the event type first to manage private links."); + } + }} + > + + Manage Private Links + + + + + + + Offer seats + + Offer seats for booking. This automatically disables guest & opt-in bookings.{" "} + + openInAppBrowser("https://cal.com/help/event-types/offer-seats", "Learn more") + } + > + Learn more + + + + + + + {/* Seats Configuration - shown when enabled */} + {props.seatsEnabled && ( + + {/* Number of seats per booking */} + + + Number of seats per booking + + + + seats + + + + {/* Share attendee information between guests - Checkbox style */} + props.setShowAttendeeInfo(!props.showAttendeeInfo)} + activeOpacity={0.7} + > + + {props.showAttendeeInfo && } + + Share attendee information between guests + + + {/* Show the number of available seats - Checkbox style */} + props.setShowAvailabilityCount(!props.showAvailabilityCount)} + activeOpacity={0.7} + > + + {props.showAvailabilityCount && ( + + )} + + Show the number of available seats + + + )} + + + + + + Hide organizer's email + + Hide organizer's email address from the booking screen, email notifications, and + calendar events. + + + + + + + + + + + Lock timezone on booking page + + + To lock the timezone on booking page, useful for in-person events.{" "} + + openInAppBrowser("https://cal.com/help/event-types/lock-timezone", "Learn more") + } + > + Learn more + + + + + + + {/* Timezone selector - shown when enabled */} + {props.lockTimezone && ( + + Timezone + { + Alert.alert( + "Select Timezone", + "To change the timezone, please use the Cal.com website for the full timezone selector.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open Website", + onPress: () => + openInAppBrowser("https://app.cal.com/event-types", "Cal.com Event Types"), + }, + ] + ); + }} + > + + {props.lockedTimezone || "Europe/London"} + + + + + )} + + + + + + + + + + Event type color + + This is only used for event type & booking differentiation within the app. It is not + displayed to bookers. + + + + + Event Type Color (Light Theme) + + + + + + + + + Event Type Color (Dark Theme) + + + + + + + + + + + + ); +} diff --git a/companion/app/event-type-detail/tabs/AvailabilityTab.tsx b/companion/components/event-type-detail/tabs/AvailabilityTab.tsx similarity index 100% rename from companion/app/event-type-detail/tabs/AvailabilityTab.tsx rename to companion/components/event-type-detail/tabs/AvailabilityTab.tsx diff --git a/companion/app/event-type-detail/tabs/BasicsTab.tsx b/companion/components/event-type-detail/tabs/BasicsTab.tsx similarity index 55% rename from companion/app/event-type-detail/tabs/BasicsTab.tsx rename to companion/components/event-type-detail/tabs/BasicsTab.tsx index 939486c06d..fc448ae236 100644 --- a/companion/app/event-type-detail/tabs/BasicsTab.tsx +++ b/companion/components/event-type-detail/tabs/BasicsTab.tsx @@ -1,8 +1,9 @@ import React from "react"; import { View, Text, TextInput, TouchableOpacity, Switch } from "react-native"; import { Ionicons } from "@expo/vector-icons"; -import { SvgImage } from "../../../components/SvgImage"; -import { defaultLocations, DefaultLocationType } from "../../../utils/defaultLocations"; + +import { LocationsList } from "../../../components/LocationsList"; +import { LocationItem, LocationOptionGroup } from "../../../types/locations"; import { slugify } from "../../../utils/slugify"; interface BasicsTabProps { @@ -25,17 +26,13 @@ interface BasicsTabProps { defaultDuration: string; setShowDefaultDurationDropdown: (show: boolean) => void; - // Location - selectedLocation: string; - setShowLocationDropdown: (show: boolean) => void; + // Multiple locations support + locations: LocationItem[]; + onAddLocation: (location: LocationItem) => void; + onRemoveLocation: (locationId: string) => void; + onUpdateLocation: (locationId: string, updates: Partial) => void; + locationOptions: LocationOptionGroup[]; conferencingLoading: boolean; - getSelectedLocationIconUrl: () => string | null; - locationAddress: string; - setLocationAddress: (value: string) => void; - locationLink: string; - setLocationLink: (value: string) => void; - locationPhone: string; - setLocationPhone: (value: string) => void; } export function BasicsTab(props: BasicsTabProps) { @@ -155,102 +152,15 @@ export function BasicsTab(props: BasicsTabProps) { {/* Location Card */} - - Location - props.setShowLocationDropdown(true)} - disabled={props.conferencingLoading} - > - - {!props.conferencingLoading && - props.selectedLocation && - props.getSelectedLocationIconUrl() && ( - - )} - - {props.conferencingLoading - ? "Loading locations..." - : props.selectedLocation || "Select location"} - - - - - - {/* Location Input Fields - shown conditionally based on selected location type */} - {(() => { - const currentLocation = defaultLocations.find( - (loc) => loc.label === props.selectedLocation - ); - if (!currentLocation || !currentLocation.organizerInputType) { - return null; - } - - if (currentLocation.organizerInputType === "text") { - // Text input for address or link - const isAddress = currentLocation.type === "inPerson"; - const isLink = currentLocation.type === "link"; - - return ( - - - {currentLocation.organizerInputLabel || - (isAddress ? "Address" : "Meeting Link")} - - { - if (isAddress) { - props.setLocationAddress(text); - } else { - props.setLocationLink(text); - } - }} - autoCapitalize="none" - autoCorrect={false} - keyboardType={isLink ? "url" : "default"} - /> - {currentLocation.messageForOrganizer && ( - - {currentLocation.messageForOrganizer} - - )} - - ); - } else if (currentLocation.organizerInputType === "phone") { - // Phone input - return ( - - - {currentLocation.organizerInputLabel || "Phone Number"} - - - {currentLocation.messageForOrganizer && ( - - {currentLocation.messageForOrganizer} - - )} - - ); - } - return null; - })()} - + Locations + ); diff --git a/companion/app/event-type-detail/tabs/LimitsTab.tsx b/companion/components/event-type-detail/tabs/LimitsTab.tsx similarity index 75% rename from companion/app/event-type-detail/tabs/LimitsTab.tsx rename to companion/components/event-type-detail/tabs/LimitsTab.tsx index 5eea809a17..f7dc002629 100644 --- a/companion/app/event-type-detail/tabs/LimitsTab.tsx +++ b/companion/components/event-type-detail/tabs/LimitsTab.tsx @@ -1,13 +1,6 @@ import React from "react"; import { View, Text, TextInput, TouchableOpacity, Switch, Animated } from "react-native"; import { Ionicons } from "@expo/vector-icons"; -import { - bufferTimeOptions, - timeUnitOptions, - frequencyUnitOptions, - durationUnitOptions, - slotIntervalOptions, -} from "../constants"; interface FrequencyLimit { id: number; @@ -48,6 +41,10 @@ interface LimitsTabProps { removeFrequencyLimit: (id: number) => void; addFrequencyLimit: () => void; + // Only show first slot + onlyShowFirstAvailableSlot: boolean; + setOnlyShowFirstAvailableSlot: (value: boolean) => void; + // Total duration limitTotalDuration: boolean; toggleTotalDuration: (value: boolean) => void; @@ -58,15 +55,13 @@ interface LimitsTabProps { removeDurationLimit: (id: number) => void; addDurationLimit: () => void; - // Only show first slot - onlyShowFirstAvailableSlot: boolean; - setOnlyShowFirstAvailableSlot: (value: boolean) => void; - // Max active bookings maxActiveBookingsPerBooker: boolean; setMaxActiveBookingsPerBooker: (value: boolean) => void; maxActiveBookingsValue: string; setMaxActiveBookingsValue: (value: string) => void; + offerReschedule: boolean; + setOfferReschedule: (value: boolean) => void; // Future bookings limitFutureBookings: boolean; @@ -159,7 +154,7 @@ export function LimitsTab(props: LimitsTabProps) { - {/* Booking Frequency Limit Card */} + {/* 1. Booking Frequency Limit Card */} @@ -190,7 +185,7 @@ export function LimitsTab(props: LimitsTabProps) { > {props.limitBookingFrequency && ( <> - {props.frequencyLimits.map((limit, index) => ( + {props.frequencyLimits.map((limit) => ( props.removeFrequencyLimit(limit.id)} > - + )} @@ -235,7 +230,28 @@ export function LimitsTab(props: LimitsTabProps) { - {/* Total Booking Duration Limit Card */} + {/* 2. Only Show First Available Slot Card */} + + + + + Only show the first slot of each day as available + + + This will limit your availability for this event type to one slot per day, scheduled + at the earliest available time. + + + + + + + {/* 3. Total Booking Duration Limit Card */} @@ -268,7 +284,7 @@ export function LimitsTab(props: LimitsTabProps) { > {props.limitTotalDuration && ( <> - {props.durationLimits.map((limit, index) => ( + {props.durationLimits.map((limit) => ( props.removeDurationLimit(limit.id)} > - + )} @@ -316,30 +332,9 @@ export function LimitsTab(props: LimitsTabProps) { - {/* Only Show First Available Slot Card */} + {/* 4. Max Active Bookings Per Booker Card */} - - - Only show the first slot of each day as available - - - This will limit your availability for this event type to one slot per day, scheduled - at the earliest available time. - - - - - - - {/* Max Active Bookings Per Booker Card */} - - Limit number of upcoming bookings per booker @@ -356,28 +351,46 @@ export function LimitsTab(props: LimitsTabProps) { /> {props.maxActiveBookingsPerBooker && ( - - { - const numericValue = text.replace(/[^0-9]/g, ""); - const num = parseInt(numericValue) || 0; - if (num >= 0) { - props.setMaxActiveBookingsValue(numericValue || "1"); - } - }} - placeholder="1" - placeholderTextColor="#8E8E93" - keyboardType="numeric" - /> + + + { + const numericValue = text.replace(/[^0-9]/g, ""); + const num = parseInt(numericValue) || 0; + if (num >= 0) { + props.setMaxActiveBookingsValue(numericValue || "1"); + } + }} + placeholder="1" + placeholderTextColor="#8E8E93" + keyboardType="numeric" + /> + bookings + + props.setOfferReschedule(!props.offerReschedule)} + > + + {props.offerReschedule && } + + + Offer to reschedule the last booking to the new time slot + + )} - {/* Limit Future Bookings Card */} + {/* 5. Limit Future Bookings Card */} - + Limit future bookings @@ -392,106 +405,87 @@ export function LimitsTab(props: LimitsTabProps) { /> {props.limitFutureBookings && ( - - - + {/* Rolling option */} + props.setFutureBookingType("rolling")} + > + props.setFutureBookingType("rolling")} > - - Rolling - - - props.setFutureBookingType("range")} - > - - Date Range - - - - {props.futureBookingType === "rolling" && ( - - + {props.futureBookingType === "rolling" && ( + + )} + + + { const numericValue = text.replace(/[^0-9]/g, ""); - const num = parseInt(numericValue) || 0; - if (num >= 0) { - props.setRollingDays(numericValue || "30"); - } + props.setRollingDays(numericValue || "30"); + props.setFutureBookingType("rolling"); }} placeholder="30" placeholderTextColor="#8E8E93" keyboardType="numeric" /> props.setRollingCalendarDays(!props.rollingCalendarDays)} + className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2" + onPress={() => { + props.setRollingCalendarDays(!props.rollingCalendarDays); + props.setFutureBookingType("rolling"); + }} > - - {props.rollingCalendarDays ? "Calendar days" : "Business days"} + + {props.rollingCalendarDays ? "calendar days" : "business days"} - - days into the future - - )} - {props.futureBookingType === "range" && ( - - - Start date - - - - End date - + into the future - )} + + + {/* Date Range option */} + props.setFutureBookingType("range")} + > + + {props.futureBookingType === "range" && ( + + )} + + + Within a date range + {props.futureBookingType === "range" && ( + + + + + )} + + )} diff --git a/companion/components/event-type-detail/tabs/RecurringTab.tsx b/companion/components/event-type-detail/tabs/RecurringTab.tsx new file mode 100644 index 0000000000..7edd79dae8 --- /dev/null +++ b/companion/components/event-type-detail/tabs/RecurringTab.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { View, Text, TextInput, TouchableOpacity, Switch } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { openInAppBrowser } from "../../../utils/browser"; + +interface RecurringTabProps { + recurringEnabled: boolean; + setRecurringEnabled: (value: boolean) => void; + recurringInterval: string; + setRecurringInterval: (value: string) => void; + recurringFrequency: "weekly" | "monthly" | "yearly"; + setRecurringFrequency: (value: "weekly" | "monthly" | "yearly") => void; + recurringOccurrences: string; + setRecurringOccurrences: (value: string) => void; + setShowFrequencyDropdown: (show: boolean) => void; +} + +// Map frequency to singular display text +const frequencyToLabel: Record = { + weekly: "week", + monthly: "month", + yearly: "year", +}; + +export function RecurringTab({ + recurringEnabled, + setRecurringEnabled, + recurringInterval, + setRecurringInterval, + recurringFrequency, + setRecurringFrequency, + recurringOccurrences, + setRecurringOccurrences, + setShowFrequencyDropdown, +}: RecurringTabProps) { + return ( + + {/* Recurring Event Toggle Card */} + + + + Recurring Event + + People can subscribe for recurring events.{" "} + + openInAppBrowser( + "https://cal.com/docs/core-features/event-types/recurring-events", + "Learn more about recurring events" + ) + } + > + Learn more + + + + + + + {/* Recurring Configuration - shown when enabled */} + {recurringEnabled && ( + + {/* Repeats Every */} + + Repeats every + + { + const numericValue = text.replace(/[^0-9]/g, ""); + if (numericValue === "" || numericValue === "0") { + setRecurringInterval("1"); + return; + } + const num = parseInt(numericValue); + if (num >= 1 && num <= 20) { + setRecurringInterval(numericValue); + } + }} + placeholder="1" + placeholderTextColor="#8E8E93" + keyboardType="numeric" + /> + setShowFrequencyDropdown(true)} + > + + {frequencyToLabel[recurringFrequency] || recurringFrequency} + + + + + + + {/* For a maximum of */} + + For a maximum of + + { + const numericValue = text.replace(/[^0-9]/g, ""); + if (numericValue === "" || numericValue === "0") { + setRecurringOccurrences("1"); + return; + } + const num = parseInt(numericValue); + if (num >= 1) { + setRecurringOccurrences(numericValue); + } + }} + placeholder="12" + placeholderTextColor="#8E8E93" + keyboardType="numeric" + /> + Events + + + + )} + + + ); +} diff --git a/companion/components/event-type-detail/utils.ts b/companion/components/event-type-detail/utils.ts new file mode 100644 index 0000000000..5f23973a1a --- /dev/null +++ b/companion/components/event-type-detail/utils.ts @@ -0,0 +1,15 @@ +/** + * Utility functions for Event Type Detail + * + * This file re-exports utilities from centralized locations for backward compatibility. + * New code should import directly from the source files. + */ + +// Re-export partial update utilities +export { buildPartialUpdatePayload, hasChanges } from "./utils/buildPartialUpdatePayload"; + +// Re-export formatting utilities from centralized location +export { formatDuration, truncateTitle, formatAppIdToDisplayName } from "../../utils/formatters"; + +// Re-export location utilities from centralized location +export { displayNameToLocationValue } from "../../utils/locationHelpers"; diff --git a/companion/components/event-type-detail/utils/buildPartialUpdatePayload.ts b/companion/components/event-type-detail/utils/buildPartialUpdatePayload.ts new file mode 100644 index 0000000000..9d0e606126 --- /dev/null +++ b/companion/components/event-type-detail/utils/buildPartialUpdatePayload.ts @@ -0,0 +1,765 @@ +import type { EventType } from "../../../services/calcom"; +import type { LocationItem } from "../../../types/locations"; +import { mapItemToApiLocation } from "../../../utils/locationHelpers"; +import { + parseBufferTime, + parseMinimumNotice, + parseFrequencyUnit, + parseSlotInterval, +} from "../../../utils/eventTypeParsers"; + +interface FrequencyLimit { + id: number; + value: string; + unit: string; +} + +interface EventTypeFormState { + eventTitle: string; + eventSlug: string; + eventDescription: string; + eventDuration: string; + isHidden: boolean; + locations: LocationItem[]; + disableGuests: boolean; + allowMultipleDurations: boolean; + selectedDurations: string[]; + defaultDuration: string; + selectedScheduleId?: number; + beforeEventBuffer: string; + afterEventBuffer: string; + minimumNoticeValue: string; + minimumNoticeUnit: string; + slotInterval: string; + limitBookingFrequency: boolean; + frequencyLimits: FrequencyLimit[]; + limitTotalDuration: boolean; + durationLimits: FrequencyLimit[]; + onlyShowFirstAvailableSlot: boolean; + maxActiveBookingsPerBooker: boolean; + maxActiveBookingsValue: string; + offerReschedule: boolean; + limitFutureBookings: boolean; + futureBookingType: "rolling" | "range"; + rollingDays: string; + rollingCalendarDays: boolean; + rangeStartDate: string; + rangeEndDate: string; + + // Advanced + requiresConfirmation: boolean; + requiresBookerEmailVerification: boolean; + hideCalendarNotes: boolean; + hideCalendarEventDetails: boolean; + hideOrganizerEmail: boolean; + lockTimezone: boolean; + allowReschedulingPastEvents: boolean; + allowBookingThroughRescheduleLink: boolean; + successRedirectUrl: string; + forwardParamsSuccessRedirect: boolean; + customReplyToEmail: string; + eventTypeColorLight: string; + eventTypeColorDark: string; + calendarEventName: string; + addToCalendarEmail: string; + selectedLayouts: string[]; + defaultLayout: string; + disableCancelling: boolean; + disableRescheduling: boolean; + sendCalVideoTranscription: boolean; + autoTranslate: boolean; + + // Seats + seatsEnabled: boolean; + seatsPerTimeSlot: string; + showAttendeeInfo: boolean; + showAvailabilityCount: boolean; + + // Recurring + recurringEnabled: boolean; + recurringInterval: string; + recurringFrequency: "weekly" | "monthly" | "yearly"; + recurringOccurrences: string; +} + +function parseDurationString(duration: string): number { + const match = duration.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; +} + +function hasMultipleDurationsChanged( + enabled: boolean, + selectedDurations: string[], + defaultDuration: string, + mainDuration: string, + original: any +): boolean { + const originalOptions = original?.lengthInMinutesOptions; + const originalHasMultiple = + originalOptions && Array.isArray(originalOptions) && originalOptions.length > 0; + + if (!enabled && !originalHasMultiple) return false; + if (!enabled && originalHasMultiple) return true; + if (enabled && !originalHasMultiple) return true; + + const currentDurations = selectedDurations.map(parseDurationString).sort((a, b) => a - b); + const originalDurations = [...originalOptions].sort((a: number, b: number) => a - b); + + if (!deepEqual(currentDurations, originalDurations)) return true; + + // Also check if the default (main) duration changed + const currentDefault = parseDurationString(defaultDuration || mainDuration); + const originalDefault = original?.lengthInMinutes; + + return currentDefault !== originalDefault; +} + +function deepEqual(a: any, b: any): boolean { + if (a === b) return true; + if (a == null || b == null) return a == b; + if (typeof a !== typeof b) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, index) => deepEqual(item, b[index])); + } + + if (typeof a === "object" && typeof b === "object") { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((key) => deepEqual(a[key], b[key])); + } + + return false; +} + +function normalizeLocation(loc: any): any { + if (!loc) return null; + + const normalized: any = { type: loc.type }; + + if (loc.type === "integration") { + normalized.integration = loc.integration; + } else if (loc.type === "address") { + normalized.address = loc.address || ""; + if (loc.public !== undefined) normalized.public = loc.public; + } else if (loc.type === "link") { + normalized.link = loc.link || ""; + if (loc.public !== undefined) normalized.public = loc.public; + } else if (loc.type === "phone") { + normalized.phone = loc.phone || ""; + if (loc.public !== undefined) normalized.public = loc.public; + } + + return normalized; +} + +function haveLocationsChanged( + currentLocations: LocationItem[], + originalLocations: any[] | undefined +): boolean { + if ((!originalLocations || originalLocations.length === 0) && currentLocations.length === 0) { + return false; + } + if (!originalLocations && currentLocations.length > 0) return true; + if (originalLocations && currentLocations.length !== originalLocations.length) return true; + + const currentMapped = currentLocations.map((loc) => normalizeLocation(mapItemToApiLocation(loc))); + const originalMapped = originalLocations!.map((loc) => normalizeLocation(loc)); + + const sortByType = (a: any, b: any) => (a?.type || "").localeCompare(b?.type || ""); + currentMapped.sort(sortByType); + originalMapped.sort(sortByType); + + return !deepEqual(currentMapped, originalMapped); +} + +function hasBookingLimitsCountChanged( + enabled: boolean, + limits: FrequencyLimit[], + original: any +): boolean { + const originalIsDisabled = + !original || + original.disabled === true || + Object.keys(original).length === 0 || + (Object.keys(original).length === 1 && original.disabled !== undefined); + + if (!enabled && originalIsDisabled) return false; + if (!enabled && !originalIsDisabled) return true; + if (enabled && originalIsDisabled) return true; + + const currentLimits: Record = {}; + limits.forEach((limit) => { + const unit = parseFrequencyUnit(limit.unit); + if (unit) { + currentLimits[unit] = parseInt(limit.value) || 1; + } + }); + + const originalLimits: Record = {}; + if (original) { + Object.keys(original).forEach((key) => { + if (key !== "disabled" && typeof original[key] === "number") { + originalLimits[key] = original[key]; + } + }); + } + + return !deepEqual(currentLimits, originalLimits); +} + +function hasBookingLimitsDurationChanged( + enabled: boolean, + limits: FrequencyLimit[], + original: any +): boolean { + const originalIsDisabled = + !original || + original.disabled === true || + Object.keys(original).length === 0 || + (Object.keys(original).length === 1 && original.disabled !== undefined); + + if (!enabled && originalIsDisabled) return false; + if (!enabled && !originalIsDisabled) return true; + if (enabled && originalIsDisabled) return true; + + const currentLimits: Record = {}; + limits.forEach((limit) => { + const unit = parseFrequencyUnit(limit.unit); + if (unit) { + currentLimits[unit] = parseInt(limit.value) || 60; + } + }); + + const originalLimits: Record = {}; + if (original) { + Object.keys(original).forEach((key) => { + if (key !== "disabled" && typeof original[key] === "number") { + originalLimits[key] = original[key]; + } + }); + } + + return !deepEqual(currentLimits, originalLimits); +} + +function hasBookingWindowChanged( + enabled: boolean, + type: "rolling" | "range", + rollingDays: string, + calendarDays: boolean, + rangeStart: string, + rangeEnd: string, + original: any +): boolean { + const originalDisabled = !original || original.disabled; + + if (!enabled && originalDisabled) return false; + if (!enabled && !originalDisabled) return true; + if (enabled && originalDisabled) return true; + + if (type === "range") { + if (original.type !== "range") return true; + const originalValue = original.value; + if (!Array.isArray(originalValue)) return true; + return originalValue[0] !== rangeStart || originalValue[1] !== rangeEnd; + } else { + const expectedType = calendarDays ? "calendarDays" : "businessDays"; + if (original.type !== expectedType) return true; + return original.value !== parseInt(rollingDays); + } +} + +function hasBookerActiveBookingsLimitChanged( + enabled: boolean, + value: string, + offerReschedule: boolean, + original: any +): boolean { + const originalDisabled = !original || original.disabled; + + if (!enabled && originalDisabled) return false; + if (!enabled && !originalDisabled) return true; + if (enabled && originalDisabled) return true; + + const originalMax = original.maximumActiveBookings ?? original.count; + return originalMax !== parseInt(value) || original.offerReschedule !== offerReschedule; +} + +function hasRecurrenceChanged( + enabled: boolean, + interval: string, + frequency: string, + occurrences: string, + original: any +): boolean { + const originalDisabled = !original || original.disabled === true; + + if (!enabled && originalDisabled) return false; + if (!enabled && !originalDisabled) return true; + if (enabled && originalDisabled) return true; + + return ( + original.interval !== parseInt(interval) || + original.frequency !== frequency || + original.occurrences !== parseInt(occurrences) + ); +} + +function hasSeatsChanged( + enabled: boolean, + perTimeSlot: string, + showAttendee: boolean, + showAvailability: boolean, + original: any +): boolean { + const originalDisabled = !original || original.disabled === true; + const originalEnabled = + original && + (original.disabled === false || (!("disabled" in original) && original.seatsPerTimeSlot)); + + if (!enabled && originalDisabled) return false; + if (!enabled && originalEnabled) return true; + if (enabled && originalDisabled) return true; + + return ( + original.seatsPerTimeSlot !== parseInt(perTimeSlot) || + original.showAttendeeInfo !== showAttendee || + original.showAvailabilityCount !== showAvailability + ); +} + +function mapLayoutToApi(layout: string): string { + const mapping: Record = { + MONTH_VIEW: "month", + WEEK_VIEW: "week", + COLUMN_VIEW: "column", + month: "month", + week: "week", + column: "column", + }; + return mapping[layout] || layout.toLowerCase().replace("_view", ""); +} + +function mapLayoutFromApi(layout: string): string { + const mapping: Record = { + month: "MONTH_VIEW", + week: "WEEK_VIEW", + column: "COLUMN_VIEW", + MONTH_VIEW: "MONTH_VIEW", + WEEK_VIEW: "WEEK_VIEW", + COLUMN_VIEW: "COLUMN_VIEW", + }; + return mapping[layout] || layout.toUpperCase() + "_VIEW"; +} + +function hasBookerLayoutsChanged( + selectedLayouts: string[], + defaultLayout: string, + original: any +): boolean { + if (!original) return selectedLayouts.length > 0; + + const originalEnabled = original.enabledLayouts || []; + const originalDefault = original.defaultLayout; + + const currentNormalized = selectedLayouts.map(mapLayoutToApi).sort(); + const originalNormalized = originalEnabled.map((l: string) => mapLayoutToApi(l)).sort(); + + if (!deepEqual(currentNormalized, originalNormalized)) return true; + return mapLayoutToApi(defaultLayout) !== mapLayoutToApi(originalDefault || ""); +} + +function hasColorsChanged(lightColor: string, darkColor: string, original: any): boolean { + if (!original) return lightColor !== "#292929" || darkColor !== "#FAFAFA"; + const originalLight = original.lightThemeHex || original.lightEventTypeColor; + const originalDark = original.darkThemeHex || original.darkEventTypeColor; + + return lightColor !== originalLight || darkColor !== originalDark; +} + +export function buildPartialUpdatePayload( + currentState: EventTypeFormState, + originalData: EventType | null +): Record { + const payload: Record = {}; + + if (!originalData) { + console.warn("buildPartialUpdatePayload called without original data"); + return {}; + } + + const original = originalData as any; + + if (currentState.eventTitle !== original.title) { + payload.title = currentState.eventTitle; + } + + if (currentState.eventSlug !== original.slug) { + payload.slug = currentState.eventSlug; + } + + if ((currentState.eventDescription || "") !== (original.description || "")) { + payload.description = currentState.eventDescription || ""; + } + + const currentDuration = parseInt(currentState.eventDuration); + + if ( + hasMultipleDurationsChanged( + currentState.allowMultipleDurations, + currentState.selectedDurations, + currentState.defaultDuration, + currentState.eventDuration, + original + ) + ) { + if (currentState.allowMultipleDurations && currentState.selectedDurations.length > 0) { + const durationOptions = currentState.selectedDurations + .map(parseDurationString) + .filter((d) => d > 0); + const defaultDurationValue = currentState.defaultDuration + ? parseDurationString(currentState.defaultDuration) + : currentDuration; + + payload.lengthInMinutes = defaultDurationValue; + payload.lengthInMinutesOptions = durationOptions; + } else { + payload.lengthInMinutes = currentDuration; + } + } else if (currentDuration !== original.lengthInMinutes && !currentState.allowMultipleDurations) { + payload.lengthInMinutes = currentDuration; + } + + if (currentState.isHidden !== original.hidden) { + payload.hidden = currentState.isHidden; + } + + if (currentState.disableGuests !== original.disableGuests) { + payload.disableGuests = currentState.disableGuests; + } + + if (haveLocationsChanged(currentState.locations, original.locations)) { + if (currentState.locations.length > 0) { + payload.locations = currentState.locations.map((loc) => mapItemToApiLocation(loc)); + } else { + payload.locations = []; + } + } + + if ( + currentState.selectedScheduleId !== undefined && + currentState.selectedScheduleId !== original.scheduleId + ) { + payload.scheduleId = currentState.selectedScheduleId; + } + + const currentBeforeBuffer = + currentState.beforeEventBuffer === "No buffer time" + ? 0 + : parseBufferTime(currentState.beforeEventBuffer); + if (currentBeforeBuffer !== (original.beforeEventBuffer || 0)) { + payload.beforeEventBuffer = currentBeforeBuffer; + } + + const currentAfterBuffer = + currentState.afterEventBuffer === "No buffer time" + ? 0 + : parseBufferTime(currentState.afterEventBuffer); + if (currentAfterBuffer !== (original.afterEventBuffer || 0)) { + payload.afterEventBuffer = currentAfterBuffer; + } + + const currentMinimumNotice = parseMinimumNotice( + currentState.minimumNoticeValue, + currentState.minimumNoticeUnit + ); + if (currentMinimumNotice !== (original.minimumBookingNotice || 0)) { + payload.minimumBookingNotice = currentMinimumNotice; + } + + const currentSlotInterval = + currentState.slotInterval === "Default" ? null : parseSlotInterval(currentState.slotInterval); + if (currentSlotInterval !== (original.slotInterval || null)) { + payload.slotInterval = currentSlotInterval; + } + + if ( + hasBookingLimitsCountChanged( + currentState.limitBookingFrequency, + currentState.frequencyLimits, + original.bookingLimitsCount + ) + ) { + if (currentState.limitBookingFrequency && currentState.frequencyLimits.length > 0) { + const limitsCount: Record = {}; + currentState.frequencyLimits.forEach((limit) => { + const unit = parseFrequencyUnit(limit.unit); + if (unit) { + limitsCount[unit] = parseInt(limit.value) || 1; + } + }); + payload.bookingLimitsCount = limitsCount; + } else { + payload.bookingLimitsCount = { disabled: true }; + } + } + + // === BOOKING LIMITS DURATION === + if ( + hasBookingLimitsDurationChanged( + currentState.limitTotalDuration, + currentState.durationLimits, + original.bookingLimitsDuration + ) + ) { + if (currentState.limitTotalDuration && currentState.durationLimits.length > 0) { + const limitsDuration: Record = {}; + currentState.durationLimits.forEach((limit) => { + const unit = parseFrequencyUnit(limit.unit); + if (unit) { + limitsDuration[unit] = parseInt(limit.value) || 60; + } + }); + payload.bookingLimitsDuration = limitsDuration; + } else { + payload.bookingLimitsDuration = { disabled: true }; + } + } + + // === ONLY SHOW FIRST AVAILABLE SLOT === + if (currentState.onlyShowFirstAvailableSlot !== (original.onlyShowFirstAvailableSlot || false)) { + payload.onlyShowFirstAvailableSlot = currentState.onlyShowFirstAvailableSlot; + } + + // === BOOKER ACTIVE BOOKINGS LIMIT === + if ( + hasBookerActiveBookingsLimitChanged( + currentState.maxActiveBookingsPerBooker, + currentState.maxActiveBookingsValue, + currentState.offerReschedule, + original.bookerActiveBookingsLimit + ) + ) { + if (currentState.maxActiveBookingsPerBooker) { + payload.bookerActiveBookingsLimit = { + maximumActiveBookings: parseInt(currentState.maxActiveBookingsValue) || 1, + offerReschedule: currentState.offerReschedule, + }; + } else { + payload.bookerActiveBookingsLimit = { disabled: true }; + } + } + + // === BOOKING WINDOW === + if ( + hasBookingWindowChanged( + currentState.limitFutureBookings, + currentState.futureBookingType, + currentState.rollingDays, + currentState.rollingCalendarDays, + currentState.rangeStartDate, + currentState.rangeEndDate, + original.bookingWindow + ) + ) { + if (currentState.limitFutureBookings) { + if (currentState.futureBookingType === "range") { + payload.bookingWindow = { + type: "range", + value: [currentState.rangeStartDate, currentState.rangeEndDate], + }; + } else { + payload.bookingWindow = { + type: currentState.rollingCalendarDays ? "calendarDays" : "businessDays", + value: parseInt(currentState.rollingDays), + }; + } + } else { + payload.bookingWindow = { disabled: true }; + } + } + + const originalRequiresConfirmation = + original.requiresConfirmation || + (original.confirmationPolicy && !original.confirmationPolicy.disabled); + if (currentState.requiresConfirmation !== originalRequiresConfirmation) { + payload.requiresConfirmation = currentState.requiresConfirmation; + } + + if ( + currentState.requiresBookerEmailVerification !== + (original.requiresBookerEmailVerification || false) + ) { + payload.requiresBookerEmailVerification = currentState.requiresBookerEmailVerification; + } + + if (currentState.hideCalendarNotes !== (original.hideCalendarNotes || false)) { + payload.hideCalendarNotes = currentState.hideCalendarNotes; + } + + if (currentState.hideCalendarEventDetails !== (original.hideCalendarEventDetails || false)) { + payload.hideCalendarEventDetails = currentState.hideCalendarEventDetails; + } + + if (currentState.hideOrganizerEmail !== (original.hideOrganizerEmail || false)) { + payload.hideOrganizerEmail = currentState.hideOrganizerEmail; + } + + if (currentState.lockTimezone !== (original.lockTimeZoneToggleOnBookingPage || false)) { + payload.lockTimeZoneToggleOnBookingPage = currentState.lockTimezone; + } + + if ( + currentState.allowReschedulingPastEvents !== (original.allowReschedulingPastBookings || false) + ) { + payload.allowReschedulingPastBookings = currentState.allowReschedulingPastEvents; + } + + if ( + currentState.allowBookingThroughRescheduleLink !== + (original.allowReschedulingCancelledBookings || false) + ) { + payload.allowReschedulingCancelledBookings = currentState.allowBookingThroughRescheduleLink; + } + + if ((currentState.customReplyToEmail || "") !== (original.customReplyToEmail || "")) { + payload.customReplyToEmail = currentState.customReplyToEmail || null; + } + + if ((currentState.successRedirectUrl || "") !== (original.successRedirectUrl || "")) { + payload.successRedirectUrl = currentState.successRedirectUrl || ""; + } + + if ( + currentState.forwardParamsSuccessRedirect !== (original.forwardParamsSuccessRedirect || false) + ) { + payload.forwardParamsSuccessRedirect = currentState.forwardParamsSuccessRedirect; + } + + if ( + hasBookerLayoutsChanged( + currentState.selectedLayouts, + currentState.defaultLayout, + original.bookerLayouts + ) + ) { + payload.bookerLayouts = { + enabledLayouts: currentState.selectedLayouts.map(mapLayoutToApi), + defaultLayout: mapLayoutToApi(currentState.defaultLayout), + }; + } + + const originalColor = original.color || original.eventTypeColor; + if ( + hasColorsChanged( + currentState.eventTypeColorLight, + currentState.eventTypeColorDark, + originalColor + ) + ) { + payload.color = { + lightThemeHex: currentState.eventTypeColorLight, + darkThemeHex: currentState.eventTypeColorDark, + }; + } + + const metadataChanges: Record = {}; + const originalMetadata = original.metadata || {}; + + if ( + currentState.disableCancelling !== + (originalMetadata.disableCancelling || original.disableCancelling || false) + ) { + metadataChanges.disableCancelling = currentState.disableCancelling; + } + + if ( + currentState.disableRescheduling !== + (originalMetadata.disableRescheduling || original.disableRescheduling || false) + ) { + metadataChanges.disableRescheduling = currentState.disableRescheduling; + } + + if ( + currentState.sendCalVideoTranscription !== + (originalMetadata.sendCalVideoTranscription || original.sendCalVideoTranscription || false) + ) { + metadataChanges.sendCalVideoTranscription = currentState.sendCalVideoTranscription; + } + + if ( + currentState.autoTranslate !== + (originalMetadata.autoTranslate || original.autoTranslate || false) + ) { + metadataChanges.autoTranslate = currentState.autoTranslate; + } + + if ((currentState.calendarEventName || "") !== (originalMetadata.calendarEventName || "")) { + if (currentState.calendarEventName) { + metadataChanges.calendarEventName = currentState.calendarEventName; + } + } + + if ((currentState.addToCalendarEmail || "") !== (originalMetadata.addToCalendarEmail || "")) { + if (currentState.addToCalendarEmail) { + metadataChanges.addToCalendarEmail = currentState.addToCalendarEmail; + } + } + + if (Object.keys(metadataChanges).length > 0) { + payload.metadata = metadataChanges; + } + + if ( + hasRecurrenceChanged( + currentState.recurringEnabled, + currentState.recurringInterval, + currentState.recurringFrequency, + currentState.recurringOccurrences, + original.recurrence + ) + ) { + if (currentState.recurringEnabled) { + payload.recurrence = { + interval: parseInt(currentState.recurringInterval) || 1, + occurrences: parseInt(currentState.recurringOccurrences) || 12, + frequency: currentState.recurringFrequency, + }; + } else { + payload.recurrence = { disabled: true }; + } + } + + if ( + hasSeatsChanged( + currentState.seatsEnabled, + currentState.seatsPerTimeSlot, + currentState.showAttendeeInfo, + currentState.showAvailabilityCount, + original.seats + ) + ) { + if (currentState.seatsEnabled) { + payload.seats = { + seatsPerTimeSlot: parseInt(currentState.seatsPerTimeSlot) || 2, + showAttendeeInfo: currentState.showAttendeeInfo, + showAvailabilityCount: currentState.showAvailabilityCount, + }; + } else { + payload.seats = { disabled: true }; + } + } + + return payload; +} + +export function hasChanges( + currentState: EventTypeFormState, + originalData: EventType | null +): boolean { + const payload = buildPartialUpdatePayload(currentState, originalData); + return Object.keys(payload).length > 0; +} diff --git a/companion/config/cache.config.ts b/companion/config/cache.config.ts new file mode 100644 index 0000000000..a8bd851baa --- /dev/null +++ b/companion/config/cache.config.ts @@ -0,0 +1,202 @@ +/** + * Cache Configuration for Cal.com Companion App + * + * This module provides centralized cache configuration with environment variable support. + * All cache durations are configurable via EXPO_PUBLIC_ prefixed environment variables. + */ + +// Helper to parse environment variable to number with fallback +const getEnvNumber = (key: string, fallback: number): number => { + const value = process.env[key]; + if (value === undefined || value === "") { + return fallback; + } + const parsed = parseInt(value, 10); + return isNaN(parsed) ? fallback : parsed; +}; + +// Convert minutes to milliseconds +// -1 means "never stale" (Infinity) +const minutesToMs = (minutes: number): number => { + if (minutes < 0) return Infinity; + return minutes * 60 * 1000; +}; + +/** + * Default cache durations in minutes + * Use -1 to indicate "never stale" (Infinity) - data only refreshes on manual reload or mutations + */ +const DEFAULT_STALE_TIME_MINUTES = 5; +const DEFAULT_BOOKINGS_STALE_TIME_MINUTES = 5; +const DEFAULT_EVENT_TYPES_STALE_TIME_MINUTES = -1; // Never stale - only refresh on mutations +const DEFAULT_SCHEDULES_STALE_TIME_MINUTES = -1; // Never stale - only refresh on mutations +const DEFAULT_USER_PROFILE_STALE_TIME_MINUTES = -1; // Never stale - only refresh on manual reload +const DEFAULT_GC_TIME_MINUTES = 1440; // Keep cached data for 24 hours (full day offline support) + +/** + * Cache configuration object with all settings + */ +export const CACHE_CONFIG = { + /** + * Default stale time for all queries (in milliseconds) + * Data older than this is considered stale and will be refetched in background + */ + defaultStaleTime: minutesToMs( + getEnvNumber("EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES", DEFAULT_STALE_TIME_MINUTES) + ), + + /** + * Garbage collection time (in milliseconds) + * Unused cache entries are removed after this duration + */ + gcTime: minutesToMs(getEnvNumber("EXPO_PUBLIC_CACHE_GC_TIME_MINUTES", DEFAULT_GC_TIME_MINUTES)), + + /** + * Resource-specific cache configurations + * + * Stale time determines when data is considered "stale" and should be refetched: + * - Bookings: 5 min - moderate refresh rate since bookings can change externally + * - Event Types: Infinity - only refresh on mutations (create/update/delete) or manual pull-to-refresh + * - Schedules: Infinity - only refresh on mutations (create/update/delete) or manual pull-to-refresh + * - User Profile: Infinity - only refresh on manual pull-to-refresh (rarely changes) + */ + bookings: { + staleTime: minutesToMs( + getEnvNumber( + "EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES", + DEFAULT_BOOKINGS_STALE_TIME_MINUTES + ) + ), + }, + + eventTypes: { + /** Infinity = never stale, only refreshes on mutations or manual reload */ + staleTime: minutesToMs( + getEnvNumber( + "EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES", + DEFAULT_EVENT_TYPES_STALE_TIME_MINUTES + ) + ), + }, + + schedules: { + /** Infinity = never stale, only refreshes on mutations or manual reload */ + staleTime: minutesToMs( + getEnvNumber( + "EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES", + DEFAULT_SCHEDULES_STALE_TIME_MINUTES + ) + ), + }, + + userProfile: { + /** Infinity = never stale, only refreshes on manual reload */ + staleTime: minutesToMs( + getEnvNumber( + "EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES", + DEFAULT_USER_PROFILE_STALE_TIME_MINUTES + ) + ), + }, + + /** + * Refetch behavior configuration + */ + refetch: { + onWindowFocus: true, + onReconnect: true, + onMount: false, + }, + + /** + * Retry configuration for failed queries + */ + retry: { + count: 3, + delay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000), + }, + + /** + * Persistence configuration + */ + persistence: { + /** Key prefix for persisted cache in storage */ + storageKey: "cal-companion-query-cache", + /** Maximum age of persisted cache before it's discarded (24 hours) */ + maxAge: 24 * 60 * 60 * 1000, + /** Throttle time for persisting cache to storage (1 second) */ + throttleTime: 1000, + }, +} as const; + +/** + * Query key factory for consistent cache key generation + * Using array-based keys enables granular cache invalidation + */ +export const queryKeys = { + // Bookings + bookings: { + all: ["bookings"] as const, + lists: () => [...queryKeys.bookings.all, "list"] as const, + list: (filters: Record) => [...queryKeys.bookings.lists(), filters] as const, + details: () => [...queryKeys.bookings.all, "detail"] as const, + detail: (uid: string) => [...queryKeys.bookings.details(), uid] as const, + }, + + // Event Types + eventTypes: { + all: ["eventTypes"] as const, + lists: () => [...queryKeys.eventTypes.all, "list"] as const, + list: (filters?: Record) => + filters + ? ([...queryKeys.eventTypes.lists(), filters] as const) + : queryKeys.eventTypes.lists(), + details: () => [...queryKeys.eventTypes.all, "detail"] as const, + detail: (id: number) => [...queryKeys.eventTypes.details(), id] as const, + }, + + // Schedules (Availability) + schedules: { + all: ["schedules"] as const, + lists: () => [...queryKeys.schedules.all, "list"] as const, + list: (filters?: Record) => + filters ? ([...queryKeys.schedules.lists(), filters] as const) : queryKeys.schedules.lists(), + details: () => [...queryKeys.schedules.all, "detail"] as const, + detail: (id: number) => [...queryKeys.schedules.details(), id] as const, + }, + + // User Profile + userProfile: { + all: ["userProfile"] as const, + current: () => [...queryKeys.userProfile.all, "current"] as const, + }, + + // Conferencing + conferencing: { + all: ["conferencing"] as const, + options: () => [...queryKeys.conferencing.all, "options"] as const, + }, + + // Webhooks + webhooks: { + all: ["webhooks"] as const, + global: () => [...queryKeys.webhooks.all, "global"] as const, + eventType: (eventTypeId: number) => + [...queryKeys.webhooks.all, "eventType", eventTypeId] as const, + }, + + // Private Links + privateLinks: { + all: ["privateLinks"] as const, + eventType: (eventTypeId: number) => [...queryKeys.privateLinks.all, eventTypeId] as const, + }, +} as const; + +/** + * Type exports for query keys + */ +export type QueryKeys = typeof queryKeys; +export type BookingQueryKeys = typeof queryKeys.bookings; +export type EventTypeQueryKeys = typeof queryKeys.eventTypes; +export type ScheduleQueryKeys = typeof queryKeys.schedules; +export type UserProfileQueryKeys = typeof queryKeys.userProfile; diff --git a/companion/config/index.ts b/companion/config/index.ts new file mode 100644 index 0000000000..af6d2e6808 --- /dev/null +++ b/companion/config/index.ts @@ -0,0 +1,12 @@ +/** + * Config Index + * + * Central export point for all configuration. + * + * @example + * ```tsx + * import { CACHE_CONFIG, queryKeys } from '../config'; + * ``` + */ + +export { CACHE_CONFIG, queryKeys } from "./cache.config"; diff --git a/companion/contexts/AuthContext.tsx b/companion/contexts/AuthContext.tsx index b93645ffcf..0f8aa1d20a 100644 --- a/companion/contexts/AuthContext.tsx +++ b/companion/contexts/AuthContext.tsx @@ -1,8 +1,4 @@ -/// - import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"; -import * as SecureStore from "expo-secure-store"; -import { Platform } from "react-native"; import { WebAuthService } from "../services/webAuth"; import { CalComAPIService } from "../services/calcom"; import { @@ -10,6 +6,7 @@ import { OAuthTokens, CalComOAuthService, } from "../services/oauthService"; +import { secureStorage } from "../utils/storage"; interface AuthContextType { isAuthenticated: boolean; @@ -36,98 +33,8 @@ interface AuthProviderProps { children: ReactNode; } -// Check if chrome.storage is available (browser extension context) -const isChromeStorageAvailable = (): boolean => { - return ( - Platform.OS === "web" && - typeof chrome !== "undefined" && - chrome.storage !== undefined && - chrome.storage.local !== undefined - ); -}; - -// Unified storage helper to abstract web/mobile/extension differences -const storage = { - get: async (key: string): Promise => { - // Use chrome.storage in browser extension context (most secure) - if (isChromeStorageAvailable()) { - return new Promise((resolve) => { - chrome.storage.local.get([key], (result) => { - resolve(result[key] || null); - }); - }); - } - // Fall back to localStorage for regular web apps - if (Platform.OS === "web") { - return localStorage.getItem(key); - } - // Use SecureStore for mobile - return await SecureStore.getItemAsync(key); - }, - set: async (key: string, value: string): Promise => { - // Use chrome.storage in browser extension context (most secure) - if (isChromeStorageAvailable()) { - return new Promise((resolve, reject) => { - chrome.storage.local.set({ [key]: value }, () => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - } else { - resolve(); - } - }); - }); - } - // Fall back to localStorage for regular web apps - if (Platform.OS === "web") { - localStorage.setItem(key, value); - return; - } - // Use SecureStore for mobile - await SecureStore.setItemAsync(key, value); - }, - remove: async (key: string): Promise => { - // Use chrome.storage in browser extension context (most secure) - if (isChromeStorageAvailable()) { - return new Promise((resolve, reject) => { - chrome.storage.local.remove(key, () => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - } else { - resolve(); - } - }); - }); - } - // Fall back to localStorage for regular web apps - if (Platform.OS === "web") { - localStorage.removeItem(key); - return; - } - // Use SecureStore for mobile - await SecureStore.deleteItemAsync(key); - }, - removeAll: async (keys: string[]): Promise => { - // Use chrome.storage in browser extension context (most secure) - if (isChromeStorageAvailable()) { - return new Promise((resolve, reject) => { - chrome.storage.local.remove(keys, () => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - } else { - resolve(); - } - }); - }); - } - // Fall back to localStorage for regular web apps - if (Platform.OS === "web") { - keys.forEach((key) => localStorage.removeItem(key)); - return; - } - // Use SecureStore for mobile - await Promise.all(keys.map((key) => SecureStore.deleteItemAsync(key))); - }, -}; +// Use the shared secure storage adapter +const storage = secureStorage; export function AuthProvider({ children }: AuthProviderProps) { const [isAuthenticated, setIsAuthenticated] = useState(false); @@ -161,7 +68,16 @@ export function AuthProvider({ children }: AuthProviderProps) { CalComAPIService.setAccessToken(token, refreshToken); try { - await CalComAPIService.getUserProfile(); + const profile = await CalComAPIService.getUserProfile(); + // Store user info for use in the app (e.g., to display "You" in bookings) + if (profile) { + setUserInfo({ + email: profile.email, + name: profile.name, + id: profile.id, + username: profile.username, + }); + } } catch (profileError) { console.error("Failed to fetch user profile:", profileError); // Don't fail login if profile fetch fails diff --git a/companion/contexts/QueryContext.tsx b/companion/contexts/QueryContext.tsx new file mode 100644 index 0000000000..03d20cf1ca --- /dev/null +++ b/companion/contexts/QueryContext.tsx @@ -0,0 +1,200 @@ +/** + * React Query Context Provider + * + * This module sets up React Query with: + * - Optimized default configurations + * - Offline persistence support + * - Environment-based cache duration settings + * - Cross-platform compatibility (mobile + extension) + */ + +import React, { ReactNode, useState, useEffect, useCallback, useMemo } from "react"; +import { Platform } from "react-native"; +import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; +import { CACHE_CONFIG } from "../config/cache.config"; +import { createQueryPersister, clearQueryCache } from "../utils/queryPersister"; + +/** + * Create and configure the QueryClient instance + */ +const createQueryClient = (): QueryClient => { + return new QueryClient({ + defaultOptions: { + queries: { + // How long data is considered fresh + staleTime: CACHE_CONFIG.defaultStaleTime, + + // How long to keep unused data in cache + gcTime: CACHE_CONFIG.gcTime, + + // Refetch behavior + refetchOnWindowFocus: CACHE_CONFIG.refetch.onWindowFocus, + refetchOnReconnect: CACHE_CONFIG.refetch.onReconnect, + refetchOnMount: CACHE_CONFIG.refetch.onMount, + + // Retry configuration + retry: CACHE_CONFIG.retry.count, + retryDelay: CACHE_CONFIG.retry.delay, + + // Network mode - always try to fetch, use cache as fallback + networkMode: "offlineFirst", + }, + mutations: { + // Retry failed mutations + retry: 1, + retryDelay: 1000, + + // Network mode for mutations + networkMode: "offlineFirst", + }, + }, + }); +}; + +/** + * Props for the QueryProvider component + */ +interface QueryProviderProps { + children: ReactNode; +} + +/** + * Context for exposing query utilities + */ +interface QueryContextValue { + /** Invalidate all queries and refetch */ + invalidateAllQueries: () => Promise; + /** Clear the persisted cache */ + clearCache: () => Promise; + /** Check if the app is online */ + isOnline: boolean; +} + +const QueryContext = React.createContext(undefined); + +/** + * QueryProvider component that wraps the app with React Query functionality + * + * Features: + * - Automatic cache persistence to device storage + * - Online/offline detection + * - Configurable cache durations via environment variables + */ +export function QueryProvider({ children }: QueryProviderProps) { + // Create QueryClient instance (stable reference) + const [queryClient] = useState(() => createQueryClient()); + + // Create persister instance (stable reference) + const [persister] = useState(() => createQueryPersister()); + + // Track online status + const [isOnline, setIsOnline] = useState(true); + + // Setup online/offline detection + useEffect(() => { + // For web/extension + if (Platform.OS === "web") { + const handleOnline = () => { + setIsOnline(true); + onlineManager.setOnline(true); + }; + const handleOffline = () => { + setIsOnline(false); + onlineManager.setOnline(false); + }; + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // Set initial state + setIsOnline(navigator.onLine); + onlineManager.setOnline(navigator.onLine); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + } + + // For React Native, we could use NetInfo but it requires additional setup + // For now, assume online on mobile (React Query handles network errors gracefully) + return undefined; + }, []); + + // Listen for reload messages from extension + useEffect(() => { + if (Platform.OS === "web") { + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === "cal-companion-reload-cache") { + queryClient.invalidateQueries(); + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + } + return undefined; + }, [queryClient]); + + /** + * Invalidate all queries and trigger refetch + */ + const invalidateAllQueries = useCallback(async () => { + await queryClient.invalidateQueries(); + }, [queryClient]); + + /** + * Clear the persisted cache + */ + const clearCache = useCallback(async () => { + queryClient.clear(); + await clearQueryCache(); + }, [queryClient]); + + const contextValue: QueryContextValue = useMemo( + () => ({ + invalidateAllQueries, + clearCache, + isOnline, + }), + [invalidateAllQueries, clearCache, isOnline] + ); + + return ( + + { + // Only persist successful queries + return query.state.status === "success"; + }, + }, + }} + > + {children} + + + ); +} + +/** + * Hook to access query utilities + */ +export function useQueryContext(): QueryContextValue { + const context = React.useContext(QueryContext); + if (context === undefined) { + throw new Error("useQueryContext must be used within a QueryProvider"); + } + return context; +} + +/** + * Export for direct QueryClient access when needed + * (e.g., for prefetching outside of components) + */ +export { QueryClient }; diff --git a/companion/contexts/index.ts b/companion/contexts/index.ts new file mode 100644 index 0000000000..7d0843eab0 --- /dev/null +++ b/companion/contexts/index.ts @@ -0,0 +1,16 @@ +/** + * Contexts Index + * + * Central export point for all React contexts. + * + * @example + * ```tsx + * import { AuthProvider, useAuth, QueryProvider } from '../contexts'; + * ``` + */ + +// Auth context +export { AuthProvider, useAuth } from "./AuthContext"; + +// Query context +export { QueryProvider, useQueryContext } from "./QueryContext"; diff --git a/companion/extension/entrypoints/background/index.ts b/companion/extension/entrypoints/background/index.ts index 557e4e7360..c3dfc361f9 100644 --- a/companion/extension/entrypoints/background/index.ts +++ b/companion/extension/entrypoints/background/index.ts @@ -1,5 +1,18 @@ /// +// ============================================ +// DEV ONLY: API Key for localhost testing +// TODO: REMOVE THIS ENTIRE SECTION BEFORE PRODUCTION +// ============================================ +const DEV_API_KEY = import.meta.env.EXPO_PUBLIC_CAL_API_KEY as string | undefined; +const IS_DEV_MODE = DEV_API_KEY && DEV_API_KEY.length > 0; +if (IS_DEV_MODE) { + console.log("Cal.com Extension: DEV MODE - API Key authentication enabled for testing"); +} +// ============================================ +// END DEV ONLY SECTION +// ============================================ + // @ts-ignore - WXT provides this globally export default defineBackground(() => { chrome.action.onClicked.addListener((tab) => { @@ -163,19 +176,38 @@ async function handleTokenExchange( async function fetchEventTypes() { const API_BASE_URL = "https://api.cal.com/v2"; + // Determine authentication method + let authHeader: string; + let authMethod: "oauth" | "apikey"; + const result = await chrome.storage.local.get(["cal_oauth_tokens"]); const oauthTokens = result.cal_oauth_tokens ? JSON.parse(result.cal_oauth_tokens as string) : null; - if (!oauthTokens?.accessToken) { + if (oauthTokens?.accessToken) { + // Use OAuth token if available + authHeader = `Bearer ${oauthTokens.accessToken}`; + authMethod = "oauth"; + } else if (IS_DEV_MODE && DEV_API_KEY) { + // ============================================ + // DEV ONLY: Fallback to API key for localhost testing + // TODO: REMOVE THIS BLOCK BEFORE PRODUCTION + // ============================================ + console.log("Cal.com Extension: Using API Key for authentication (DEV MODE)"); + authHeader = `Bearer ${DEV_API_KEY}`; + authMethod = "apikey"; + // ============================================ + // END DEV ONLY BLOCK + // ============================================ + } else { throw new Error("No OAuth access token found. Please sign in with OAuth."); } // Get current user to retrieve username const userResponse = await fetch(`${API_BASE_URL}/me`, { headers: { - Authorization: `Bearer ${oauthTokens.accessToken}`, + Authorization: authHeader, "Content-Type": "application/json", "cal-api-version": "2024-06-11", }, @@ -198,7 +230,7 @@ async function fetchEventTypes() { const response = await fetch(endpoint, { headers: { - Authorization: `Bearer ${oauthTokens.accessToken}`, + Authorization: authHeader, "Content-Type": "application/json", "cal-api-version": "2024-06-14", }, diff --git a/companion/extension/entrypoints/content.ts b/companion/extension/entrypoints/content.ts index 18483faf5d..0d814bd9cb 100644 --- a/companion/extension/entrypoints/content.ts +++ b/companion/extension/entrypoints/content.ts @@ -230,6 +230,29 @@ export default defineContentScript({ toggleButton.style.justifyContent = "center"; toggleButton.title = "Toggle sidebar"; + // Create reload button + const reloadButton = document.createElement("button"); + reloadButton.innerHTML = ` + + + +`; + reloadButton.style.width = "40px"; + reloadButton.style.height = "40px"; + reloadButton.style.borderRadius = "50%"; + reloadButton.style.border = "1px solid rgba(255, 255, 255, 0.5)"; + reloadButton.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; + reloadButton.style.backdropFilter = "blur(10px)"; + reloadButton.style.color = "white"; + reloadButton.style.cursor = "pointer"; + reloadButton.style.fontSize = "16px"; + reloadButton.style.boxShadow = "0 2px 8px rgba(0,0,0,0.2)"; + reloadButton.style.transition = "all 0.2s ease"; + reloadButton.style.display = "flex"; + reloadButton.style.alignItems = "center"; + reloadButton.style.justifyContent = "center"; + reloadButton.title = "Reload data"; + // Create close button const closeButton = document.createElement("button"); closeButton.innerHTML = ` @@ -268,6 +291,37 @@ export default defineContentScript({ closeButton.style.transform = "scale(1)"; }); + reloadButton.addEventListener("mouseenter", () => { + reloadButton.style.transform = "scale(1.1)"; + }); + reloadButton.addEventListener("mouseleave", () => { + reloadButton.style.transform = "scale(1)"; + }); + + // Reload functionality - sends message to iframe to invalidate cache + reloadButton.addEventListener("click", () => { + // Add spinning animation + reloadButton.style.animation = "spin 0.5s ease-in-out"; + setTimeout(() => { + reloadButton.style.animation = ""; + }, 500); + + // Send message to iframe to reload cache + if (iframe.contentWindow) { + iframe.contentWindow.postMessage({ type: "cal-companion-reload-cache" }, "*"); + } + }); + + // Add spin animation style + const styleSheet = document.createElement("style"); + styleSheet.textContent = ` + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + `; + document.head.appendChild(styleSheet); + // Toggle functionality toggleButton.addEventListener("click", () => { if (isClosed) return; @@ -300,6 +354,7 @@ export default defineContentScript({ // Add buttons to container buttonsContainer.appendChild(toggleButton); + buttonsContainer.appendChild(reloadButton); buttonsContainer.appendChild(closeButton); // Add everything to DOM @@ -1508,108 +1563,6 @@ export default defineContentScript({ } } - /** - * Auto-remove all Cal.com action bars before sending email - */ - function setupAutoRemoveOnSend() { - try { - // Helper function to remove all action bars and marked Google chips - const removeAllActionBars = () => { - // Remove action bars - const allActionBars = document.querySelectorAll(".cal-companion-action-bar"); - if (allActionBars.length > 0) { - console.log(`Cal.com: Removing ${allActionBars.length} action bar(s) before send`); - allActionBars.forEach((bar) => { - try { - // Call cleanup function to remove event listeners before removing DOM node - if ((bar as any).__cleanup) { - (bar as any).__cleanup(); - } - bar.remove(); - } catch (error) { - console.warn("Cal.com: Failed to remove action bar:", error); - } - }); - } - - // Remove Google chips that were marked for removal (user used Cal.com) - const markedChips = document.querySelectorAll( - '.gmail_chip[data-calcom-remove-on-send="true"]' - ); - if (markedChips.length > 0) { - console.log( - `Cal.com: Removing ${markedChips.length} Google chip(s) before send (user used Cal.com)` - ); - markedChips.forEach((chip) => { - try { - chip.remove(); - } catch (error) { - console.warn("Cal.com: Failed to remove Google chip:", error); - } - }); - } - }; - - // Method 1: Watch for clicks on Send button - document.addEventListener( - "click", - (e) => { - const target = e.target as HTMLElement; - - // Check if the clicked element is a Send button - const isSendButton = - target.getAttribute("data-tooltip")?.includes("Send") || - target.getAttribute("aria-label")?.includes("Send") || - target.textContent?.trim() === "Send" || - target.closest('[data-tooltip*="Send"]') || - target.closest('[aria-label*="Send"]') || - target - .closest('[role="button"][data-tooltip]') - ?.getAttribute("data-tooltip") - ?.includes("Send"); - - if (isSendButton) { - console.log("Cal.com: Send button clicked"); - removeAllActionBars(); - } - }, - true - ); // Use capture phase - - // Method 2: Watch for keyboard shortcuts (Ctrl+Enter / Cmd+Enter) - document.addEventListener( - "keydown", - (e) => { - const isCtrlOrCmd = e.ctrlKey || e.metaKey; - const isEnter = e.key === "Enter"; - - if (isCtrlOrCmd && isEnter) { - // Check if we're in a compose window - const activeElement = document.activeElement; - const isInCompose = - activeElement?.getAttribute("role") === "textbox" || - activeElement?.getAttribute("contenteditable") === "true" || - activeElement?.closest('[role="textbox"]'); - - if (isInCompose) { - console.log("Cal.com: Send keyboard shortcut detected (Ctrl/Cmd+Enter)"); - removeAllActionBars(); - } - } - }, - true - ); - - // Note: Action bars are now overlays (like Grammarly), so they won't be included in emails. - // We keep the click and keyboard listeners for clean UI (removing overlays when sending). - // Removed the MutationObserver as it was too aggressive and removing action bars prematurely. - - console.log("Cal.com: Auto-remove on send listeners added (click, keyboard)"); - } catch (error) { - console.warn("Cal.com: Failed to setup auto-remove on send:", error); - } - } - /** * Watch for Google Calendar scheduling chips and add Cal.com suggestion button */ @@ -1973,8 +1926,16 @@ export default defineContentScript({ showGmailNotification("Cal.com embed inserted!", "success"); console.log("Cal.com: ✅ Email embed inserted successfully"); - // Mark chip for removal on send (don't remove yet - keep it visible for user reference) - chipElement.setAttribute("data-calcom-remove-on-send", "true"); + // Immediately remove the Google chip and action bar + try { + chipElement.remove(); + if ((actionBar as any).__cleanup) { + (actionBar as any).__cleanup(); + } + actionBar.remove(); + } catch (removeError) { + console.warn("Cal.com: Failed to remove chip/action bar:", removeError); + } } else { showGmailNotification("Failed to insert embed", "error"); } @@ -2660,8 +2621,26 @@ export default defineContentScript({ showGmailNotification("Cal.com link inserted!", "success"); backdrop.remove(); - // Mark chip for removal on send (don't remove yet - keep it visible for user reference) - chipElement.setAttribute("data-calcom-remove-on-send", "true"); + // Immediately remove the Google chip and its action bar + try { + const scheduleId = chipElement.getAttribute("data-ad-hoc-schedule-id"); + const actionBar = scheduleId + ? document.querySelector( + `.cal-companion-action-bar[data-schedule-id="${scheduleId}"]` + ) + : chipElement.parentElement?.querySelector(".cal-companion-action-bar"); + + chipElement.remove(); + + if (actionBar) { + if ((actionBar as any).__cleanup) { + (actionBar as any).__cleanup(); + } + actionBar.remove(); + } + } catch (removeError) { + console.warn("Cal.com: Failed to remove chip/action bar:", removeError); + } } else { showGmailNotification("Failed to insert link", "error"); } @@ -2911,9 +2890,6 @@ export default defineContentScript({ // Start watching for Google Calendar chips watchForGoogleChips(); - - // Setup auto-remove action bars before sending email - setupAutoRemoveOnSend(); } }, }); diff --git a/companion/hooks/index.ts b/companion/hooks/index.ts new file mode 100644 index 0000000000..f459fcb9c0 --- /dev/null +++ b/companion/hooks/index.ts @@ -0,0 +1,72 @@ +/** + * Query Hooks Index + * + * Central export point for all React Query hooks. + * Import hooks from this file for clean imports: + * + * @example + * ```tsx + * import { useBookings, useEventTypes, useSchedules } from '../hooks'; + * ``` + */ + +// Bookings hooks +export { + useBookings, + useBookingByUid, + useCancelBooking, + useRescheduleBooking, + useConfirmBooking, + useDeclineBooking, + usePrefetchBookings, + useInvalidateBookings, + type BookingFilters, + type Booking, +} from "./useBookings"; + +// Event Types hooks +export { + useEventTypes, + useEventTypeById, + useCreateEventType, + useUpdateEventType, + useDeleteEventType, + useDuplicateEventType, + usePrefetchEventTypes, + useInvalidateEventTypes, + type EventType, + type CreateEventTypeInput, +} from "./useEventTypes"; + +// Schedules (Availability) hooks +export { + useSchedules, + useScheduleById, + useCreateSchedule, + useUpdateSchedule, + useSetScheduleAsDefault, + useDeleteSchedule, + useDuplicateSchedule, + usePrefetchSchedules, + useInvalidateSchedules, + type Schedule, + type CreateScheduleInput, + type UpdateScheduleInput, +} from "./useSchedules"; + +// User Profile hooks +export { + useUserProfile, + useUsername, + useUpdateUserProfile, + usePrefetchUserProfile, + useInvalidateUserProfile, + type UserProfile, + type UpdateUserProfileInput, +} from "./useUserProfile"; + +// Re-export query keys for advanced use cases +export { queryKeys } from "../config/cache.config"; + +// Re-export query context utilities +export { useQueryContext } from "../contexts/QueryContext"; diff --git a/companion/hooks/useBookings.ts b/companion/hooks/useBookings.ts new file mode 100644 index 0000000000..2221b86b03 --- /dev/null +++ b/companion/hooks/useBookings.ts @@ -0,0 +1,267 @@ +/** + * Bookings Query Hooks + * + * This module provides React Query hooks for fetching and mutating bookings data. + * It integrates with the existing CalComAPIService and provides: + * - Automatic caching with configurable stale times + * - Pull-to-refresh support via refetch + * - Optimistic updates for mutations + * - Cache invalidation on mutations + */ + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { CalComAPIService, Booking } from "../services/calcom"; +import { CACHE_CONFIG, queryKeys } from "../config/cache.config"; + +/** + * Filter options for fetching bookings + */ +export interface BookingFilters { + status?: string[]; + fromDate?: string; + toDate?: string; + eventTypeId?: number; + limit?: number; + offset?: number; + [key: string]: unknown; // Index signature for Record compatibility +} + +/** + * Hook to fetch bookings with optional filters + * + * @param filters - Optional filters for the bookings query + * @returns Query result with bookings data, loading state, error, and refetch function + * + * @example + * ```tsx + * const { data: bookings, isLoading, refetch } = useBookings({ status: ['upcoming'] }); + * + * // Pull-to-refresh + * + * ``` + */ +export function useBookings(filters?: BookingFilters) { + return useQuery({ + queryKey: queryKeys.bookings.list(filters || {}), + queryFn: () => CalComAPIService.getBookings(filters), + staleTime: CACHE_CONFIG.bookings.staleTime, + // Keep previous data while fetching new data (smoother UX) + placeholderData: (previousData) => previousData, + // Don't retry on network errors (keeps cache intact) + retry: (failureCount, error) => { + // Don't retry network errors - keeps cached data visible + if (error?.message?.includes("Network") || error?.message?.includes("fetch")) { + return false; + } + return failureCount < 2; + }, + // Keep showing cached data even if refetch fails + refetchOnReconnect: true, + }); +} + +/** + * Hook to fetch a single booking by UID + * + * @param uid - The unique identifier of the booking + * @returns Query result with booking data + * + * @example + * ```tsx + * const { data: booking, isLoading } = useBookingByUid('abc-123'); + * ``` + */ +export function useBookingByUid(uid: string | undefined) { + return useQuery({ + queryKey: queryKeys.bookings.detail(uid || ""), + queryFn: () => CalComAPIService.getBookingByUid(uid!), + enabled: !!uid, // Only fetch when uid is provided + staleTime: CACHE_CONFIG.bookings.staleTime, + }); +} + +/** + * Hook to cancel a booking + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: cancelBooking, isPending } = useCancelBooking(); + * + * cancelBooking({ uid: 'abc-123', reason: 'No longer needed' }); + * ``` + */ +export function useCancelBooking() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uid, reason }: { uid: string; reason?: string }) => + CalComAPIService.cancelBooking(uid, reason), + onSuccess: (_, variables) => { + // Invalidate all booking queries to refetch fresh data + queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all }); + + // Also invalidate the specific booking detail + queryClient.invalidateQueries({ + queryKey: queryKeys.bookings.detail(variables.uid), + }); + }, + onError: (error) => { + console.error("Failed to cancel booking:", error); + }, + }); +} + +/** + * Hook to reschedule a booking + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: rescheduleBooking, isPending } = useRescheduleBooking(); + * + * rescheduleBooking({ + * uid: 'abc-123', + * start: '2024-01-15T10:00:00Z', + * reschedulingReason: 'Conflict with another meeting' + * }); + * ``` + */ +export function useRescheduleBooking() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + uid, + start, + reschedulingReason, + }: { + uid: string; + start: string; + reschedulingReason?: string; + }) => CalComAPIService.rescheduleBooking(uid, { start, reschedulingReason }), + onSuccess: (_, variables) => { + // Invalidate all booking queries + queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all }); + + // Also invalidate the specific booking detail + queryClient.invalidateQueries({ + queryKey: queryKeys.bookings.detail(variables.uid), + }); + }, + onError: (error) => { + console.error("Failed to reschedule booking:", error); + }, + }); +} + +/** + * Hook to confirm a pending booking + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: confirmBooking, isPending } = useConfirmBooking(); + * + * confirmBooking({ uid: 'abc-123' }); + * ``` + */ +export function useConfirmBooking() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uid }: { uid: string }) => CalComAPIService.confirmBooking(uid), + onSuccess: (_, variables) => { + // Invalidate all booking queries to refetch fresh data + queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all }); + + // Also invalidate the specific booking detail + queryClient.invalidateQueries({ + queryKey: queryKeys.bookings.detail(variables.uid), + }); + }, + onError: (error) => { + console.error("Failed to confirm booking:", error); + }, + }); +} + +/** + * Hook to decline a pending booking + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: declineBooking, isPending } = useDeclineBooking(); + * + * declineBooking({ uid: 'abc-123', reason: 'Schedule conflict' }); + * ``` + */ +export function useDeclineBooking() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uid, reason }: { uid: string; reason?: string }) => + CalComAPIService.declineBooking(uid, reason), + onSuccess: (_, variables) => { + // Invalidate all booking queries to refetch fresh data + queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all }); + + // Also invalidate the specific booking detail + queryClient.invalidateQueries({ + queryKey: queryKeys.bookings.detail(variables.uid), + }); + }, + onError: (error) => { + console.error("Failed to decline booking:", error); + }, + }); +} + +/** + * Hook to prefetch bookings (useful for navigation) + * + * @returns Function to prefetch bookings + * + * @example + * ```tsx + * const prefetchBookings = usePrefetchBookings(); + * + * // Prefetch when user hovers over bookings tab + * onHover={() => prefetchBookings({ status: ['upcoming'] })} + * ``` + */ +export function usePrefetchBookings() { + const queryClient = useQueryClient(); + + return (filters?: BookingFilters) => { + queryClient.prefetchQuery({ + queryKey: queryKeys.bookings.list(filters || {}), + queryFn: () => CalComAPIService.getBookings(filters), + staleTime: CACHE_CONFIG.bookings.staleTime, + }); + }; +} + +/** + * Hook to invalidate all bookings cache + * Useful when you know data has changed externally + * + * @returns Function to invalidate bookings cache + */ +export function useInvalidateBookings() { + const queryClient = useQueryClient(); + + return () => { + queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all }); + }; +} + +/** + * Type exports for consumers + */ +export type { Booking }; diff --git a/companion/hooks/useEventTypes.ts b/companion/hooks/useEventTypes.ts new file mode 100644 index 0000000000..edcf9fe652 --- /dev/null +++ b/companion/hooks/useEventTypes.ts @@ -0,0 +1,274 @@ +/** + * Event Types Query Hooks + * + * This module provides React Query hooks for fetching and mutating event types. + * It integrates with the existing CalComAPIService and provides: + * - Automatic caching with configurable stale times + * - Pull-to-refresh support via refetch + * - Optimistic updates for mutations + * - Cache invalidation on create/update/delete + */ + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { CalComAPIService, EventType, CreateEventTypeInput } from "../services/calcom"; +import { CACHE_CONFIG, queryKeys } from "../config/cache.config"; + +/** + * Hook to fetch all event types + * + * @returns Query result with event types data, loading state, error, and refetch function + * + * @example + * ```tsx + * const { data: eventTypes, isLoading, refetch, isRefetching } = useEventTypes(); + * + * // Pull-to-refresh + * + * ``` + */ +export function useEventTypes() { + return useQuery({ + queryKey: queryKeys.eventTypes.lists(), + queryFn: () => CalComAPIService.getEventTypes(), + staleTime: CACHE_CONFIG.eventTypes.staleTime, + // Keep previous data while fetching new data (smoother UX) + placeholderData: (previousData) => previousData, + // Don't retry on network errors (keeps cache intact) + retry: (failureCount, error) => { + if (error?.message?.includes("Network") || error?.message?.includes("fetch")) { + return false; + } + return failureCount < 2; + }, + refetchOnReconnect: true, + }); +} + +/** + * Hook to fetch a single event type by ID + * + * @param id - The ID of the event type + * @returns Query result with event type data + * + * @example + * ```tsx + * const { data: eventType, isLoading } = useEventTypeById(123); + * ``` + */ +export function useEventTypeById(id: number | undefined) { + return useQuery({ + queryKey: queryKeys.eventTypes.detail(id || 0), + queryFn: () => CalComAPIService.getEventTypeById(id!), + enabled: !!id, // Only fetch when id is provided + staleTime: CACHE_CONFIG.eventTypes.staleTime, + }); +} + +/** + * Hook to create a new event type + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: createEventType, isPending } = useCreateEventType(); + * + * createEventType({ + * title: 'Quick Chat', + * slug: 'quick-chat', + * lengthInMinutes: 15, + * }); + * ``` + */ +export function useCreateEventType() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateEventTypeInput) => CalComAPIService.createEventType(input), + onSuccess: (newEventType) => { + // Invalidate the list to include the new event type + queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() }); + + // Optionally, add the new event type to cache immediately + queryClient.setQueryData(queryKeys.eventTypes.detail(newEventType.id), newEventType); + }, + onError: (error) => { + console.error("Failed to create event type:", error); + }, + }); +} + +/** + * Hook to update an event type + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: updateEventType, isPending } = useUpdateEventType(); + * + * updateEventType({ + * id: 123, + * updates: { title: 'Updated Title' } + * }); + * ``` + */ +export function useUpdateEventType() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, updates }: { id: number; updates: Partial }) => + CalComAPIService.updateEventType(id, updates), + onSuccess: (updatedEventType, variables) => { + // Invalidate the list + queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() }); + + // Update the specific event type in cache + queryClient.setQueryData(queryKeys.eventTypes.detail(variables.id), updatedEventType); + }, + onError: (error) => { + console.error("Failed to update event type:", error); + }, + }); +} + +/** + * Hook to delete an event type + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: deleteEventType, isPending } = useDeleteEventType(); + * + * deleteEventType(123); + * ``` + */ +export function useDeleteEventType() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => CalComAPIService.deleteEventType(id), + onMutate: async (deletedId) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: queryKeys.eventTypes.lists() }); + + // Snapshot the previous value + const previousEventTypes = queryClient.getQueryData( + queryKeys.eventTypes.lists() + ); + + // Optimistically remove from the list + if (previousEventTypes) { + queryClient.setQueryData( + queryKeys.eventTypes.lists(), + previousEventTypes.filter((et) => et.id !== deletedId) + ); + } + + return { previousEventTypes }; + }, + onError: (error, _deletedId, context) => { + // Rollback on error + if (context?.previousEventTypes) { + queryClient.setQueryData(queryKeys.eventTypes.lists(), context.previousEventTypes); + } + console.error("Failed to delete event type:", error); + }, + onSettled: () => { + // Always refetch after error or success + queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() }); + }, + }); +} + +/** + * Hook to duplicate an event type + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: duplicateEventType, isPending } = useDuplicateEventType(); + * + * duplicateEventType({ + * eventType: existingEventType, + * existingEventTypes: allEventTypes + * }); + * ``` + */ +export function useDuplicateEventType() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + eventType, + existingEventTypes, + }: { + eventType: EventType; + existingEventTypes: EventType[]; + }) => { + // Generate a new title and slug for the duplicate + const newTitle = `${eventType.title} (copy)`; + let newSlug = `${eventType.slug}-copy`; + + // Check if slug already exists and append a number if needed + let counter = 1; + while (existingEventTypes.some((et) => et.slug === newSlug)) { + newSlug = `${eventType.slug}-copy-${counter}`; + counter++; + } + + const duration = eventType.lengthInMinutes ?? eventType.length ?? 15; + + return CalComAPIService.createEventType({ + title: newTitle, + slug: newSlug, + lengthInMinutes: duration, + description: eventType.description || undefined, + }); + }, + onSuccess: () => { + // Invalidate the list to include the duplicated event type + queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() }); + }, + onError: (error) => { + console.error("Failed to duplicate event type:", error); + }, + }); +} + +/** + * Hook to prefetch event types (useful for navigation) + * + * @returns Function to prefetch event types + */ +export function usePrefetchEventTypes() { + const queryClient = useQueryClient(); + + return () => { + queryClient.prefetchQuery({ + queryKey: queryKeys.eventTypes.lists(), + queryFn: () => CalComAPIService.getEventTypes(), + staleTime: CACHE_CONFIG.eventTypes.staleTime, + }); + }; +} + +/** + * Hook to invalidate all event types cache + * + * @returns Function to invalidate event types cache + */ +export function useInvalidateEventTypes() { + const queryClient = useQueryClient(); + + return () => { + queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.all }); + }; +} + +/** + * Type exports for consumers + */ +export type { EventType, CreateEventTypeInput }; diff --git a/companion/hooks/useSchedules.ts b/companion/hooks/useSchedules.ts new file mode 100644 index 0000000000..f2746bbb96 --- /dev/null +++ b/companion/hooks/useSchedules.ts @@ -0,0 +1,327 @@ +/** + * Schedules (Availability) Query Hooks + * + * This module provides React Query hooks for fetching and mutating schedules. + * It integrates with the existing CalComAPIService and provides: + * - Automatic caching with configurable stale times + * - Pull-to-refresh support via refetch + * - Optimistic updates for mutations + * - Cache invalidation on create/update/delete + */ + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { CalComAPIService, Schedule } from "../services/calcom"; +import { CACHE_CONFIG, queryKeys } from "../config/cache.config"; + +/** + * Sort schedules: default first, then alphabetically by name + */ +function sortSchedules(schedules: Schedule[]): Schedule[] { + return schedules.sort((a, b) => { + if (a.isDefault && !b.isDefault) return -1; + if (!a.isDefault && b.isDefault) return 1; + return a.name.localeCompare(b.name); + }); +} + +/** + * Schedule creation input type + */ +export interface CreateScheduleInput { + name: string; + timeZone: string; + isDefault?: boolean; + availability?: Array<{ + days: string[]; + startTime: string; + endTime: string; + }>; + overrides?: Array<{ + date: string; + startTime: string; + endTime: string; + }>; +} + +/** + * Schedule update input type + */ +export interface UpdateScheduleInput { + isDefault?: boolean; + name?: string; + timeZone?: string; + availability?: Array<{ + days: string[]; + startTime: string; + endTime: string; + }>; + overrides?: Array<{ + date: string; + startTime: string; + endTime: string; + }>; +} + +/** + * Hook to fetch all schedules + * + * @returns Query result with schedules data, loading state, error, and refetch function + * + * @example + * ```tsx + * const { data: schedules, isLoading, refetch, isRefetching } = useSchedules(); + * + * // Pull-to-refresh + * + * ``` + */ +export function useSchedules() { + return useQuery({ + queryKey: queryKeys.schedules.lists(), + queryFn: async () => { + const schedules = await CalComAPIService.getSchedules(); + return sortSchedules(schedules); + }, + staleTime: CACHE_CONFIG.schedules.staleTime, + // Keep previous data while fetching new data (smoother UX) + placeholderData: (previousData) => previousData, + // Don't retry on network errors (keeps cache intact) + retry: (failureCount, error) => { + if (error?.message?.includes("Network") || error?.message?.includes("fetch")) { + return false; + } + return failureCount < 2; + }, + refetchOnReconnect: true, + }); +} + +/** + * Hook to fetch a single schedule by ID + * + * @param id - The ID of the schedule + * @returns Query result with schedule data + * + * @example + * ```tsx + * const { data: schedule, isLoading } = useScheduleById(123); + * ``` + */ +export function useScheduleById(id: number | undefined) { + return useQuery({ + queryKey: queryKeys.schedules.detail(id || 0), + queryFn: () => CalComAPIService.getScheduleById(id!), + enabled: !!id, // Only fetch when id is provided + staleTime: CACHE_CONFIG.schedules.staleTime, + }); +} + +/** + * Hook to create a new schedule + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: createSchedule, isPending } = useCreateSchedule(); + * + * createSchedule({ + * name: 'Working Hours', + * timeZone: 'America/New_York', + * availability: [ + * { days: ['Monday', 'Tuesday'], startTime: '09:00', endTime: '17:00' } + * ] + * }); + * ``` + */ +export function useCreateSchedule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateScheduleInput) => CalComAPIService.createSchedule(input), + onSuccess: (newSchedule) => { + // Invalidate the list to include the new schedule + queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() }); + + // Optionally, add the new schedule to cache immediately + queryClient.setQueryData(queryKeys.schedules.detail(newSchedule.id), newSchedule); + }, + onError: (error) => { + console.error("Failed to create schedule:", error); + }, + }); +} + +/** + * Hook to update a schedule + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: updateSchedule, isPending } = useUpdateSchedule(); + * + * updateSchedule({ + * id: 123, + * updates: { name: 'Updated Schedule Name' } + * }); + * ``` + */ +export function useUpdateSchedule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, updates }: { id: number; updates: UpdateScheduleInput }) => + CalComAPIService.updateSchedule(id, updates), + onSuccess: (updatedSchedule, variables) => { + // Invalidate the list + queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() }); + + // Update the specific schedule in cache + queryClient.setQueryData(queryKeys.schedules.detail(variables.id), updatedSchedule); + }, + onError: (error) => { + console.error("Failed to update schedule:", error); + }, + }); +} + +/** + * Hook to set a schedule as default + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: setAsDefault, isPending } = useSetScheduleAsDefault(); + * + * setAsDefault(123); + * ``` + */ +export function useSetScheduleAsDefault() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => CalComAPIService.updateSchedule(id, { isDefault: true }), + onSuccess: () => { + // Invalidate all schedules to update the default flag + queryClient.invalidateQueries({ queryKey: queryKeys.schedules.all }); + }, + onError: (error) => { + console.error("Failed to set schedule as default:", error); + }, + }); +} + +/** + * Hook to delete a schedule + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: deleteSchedule, isPending } = useDeleteSchedule(); + * + * deleteSchedule(123); + * ``` + */ +export function useDeleteSchedule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => CalComAPIService.deleteSchedule(id), + onMutate: async (deletedId) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: queryKeys.schedules.lists() }); + + // Snapshot the previous value + const previousSchedules = queryClient.getQueryData(queryKeys.schedules.lists()); + + // Optimistically remove from the list + if (previousSchedules) { + queryClient.setQueryData( + queryKeys.schedules.lists(), + previousSchedules.filter((s) => s.id !== deletedId) + ); + } + + return { previousSchedules }; + }, + onError: (error, _deletedId, context) => { + // Rollback on error + if (context?.previousSchedules) { + queryClient.setQueryData(queryKeys.schedules.lists(), context.previousSchedules); + } + console.error("Failed to delete schedule:", error); + }, + onSettled: () => { + // Always refetch after error or success + queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() }); + }, + }); +} + +/** + * Hook to duplicate a schedule + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: duplicateSchedule, isPending } = useDuplicateSchedule(); + * + * duplicateSchedule(123); + * ``` + */ +export function useDuplicateSchedule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => CalComAPIService.duplicateSchedule(id), + onSuccess: () => { + // Invalidate the list to include the duplicated schedule + queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() }); + }, + onError: (error) => { + console.error("Failed to duplicate schedule:", error); + }, + }); +} + +/** + * Hook to prefetch schedules (useful for navigation) + * + * @returns Function to prefetch schedules + */ +export function usePrefetchSchedules() { + const queryClient = useQueryClient(); + + return () => { + queryClient.prefetchQuery({ + queryKey: queryKeys.schedules.lists(), + queryFn: async () => { + const schedules = await CalComAPIService.getSchedules(); + return sortSchedules(schedules); + }, + staleTime: CACHE_CONFIG.schedules.staleTime, + }); + }; +} + +/** + * Hook to invalidate all schedules cache + * + * @returns Function to invalidate schedules cache + */ +export function useInvalidateSchedules() { + const queryClient = useQueryClient(); + + return () => { + queryClient.invalidateQueries({ queryKey: queryKeys.schedules.all }); + }; +} + +/** + * Type exports for consumers + */ +export type { Schedule }; diff --git a/companion/hooks/useUserProfile.ts b/companion/hooks/useUserProfile.ts new file mode 100644 index 0000000000..944aa9c885 --- /dev/null +++ b/companion/hooks/useUserProfile.ts @@ -0,0 +1,159 @@ +/** + * User Profile Query Hooks + * + * This module provides React Query hooks for fetching and updating user profile. + * It integrates with the existing CalComAPIService and provides: + * - Automatic caching with configurable stale times + * - Profile update mutations with cache invalidation + */ + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { CalComAPIService, UserProfile } from "../services/calcom"; +import { CACHE_CONFIG, queryKeys } from "../config/cache.config"; + +/** + * User profile update input type + */ +export interface UpdateUserProfileInput { + email?: string; + name?: string; + timeFormat?: number; + defaultScheduleId?: number; + weekStart?: string; + timeZone?: string; + locale?: string; + avatarUrl?: string; + bio?: string; + metadata?: Record; +} + +/** + * Hook to fetch the current user profile + * + * @returns Query result with user profile data, loading state, error, and refetch function + * + * @example + * ```tsx + * const { data: profile, isLoading } = useUserProfile(); + * + * if (profile) { + * console.log(profile.username, profile.email); + * } + * ``` + */ +export function useUserProfile() { + return useQuery({ + queryKey: queryKeys.userProfile.current(), + queryFn: () => CalComAPIService.getUserProfile(), + staleTime: CACHE_CONFIG.userProfile.staleTime, + // Keep previous data while fetching new data (smoother UX) + placeholderData: (previousData) => previousData, + }); +} + +/** + * Hook to get the current username + * + * @returns Query result with username + * + * @example + * ```tsx + * const { data: username } = useUsername(); + * ``` + */ +export function useUsername() { + const { data: profile, ...rest } = useUserProfile(); + + return { + ...rest, + data: profile?.username, + }; +} + +/** + * Hook to update the user profile + * + * @returns Mutation function and state + * + * @example + * ```tsx + * const { mutate: updateProfile, isPending } = useUpdateUserProfile(); + * + * updateProfile({ + * name: 'New Name', + * timeZone: 'America/New_York' + * }); + * ``` + */ +export function useUpdateUserProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (updates: UpdateUserProfileInput) => CalComAPIService.updateUserProfile(updates), + onMutate: async (newData) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: queryKeys.userProfile.current() }); + + // Snapshot the previous value + const previousProfile = queryClient.getQueryData( + queryKeys.userProfile.current() + ); + + // Optimistically update to the new value + if (previousProfile) { + queryClient.setQueryData(queryKeys.userProfile.current(), { + ...previousProfile, + ...newData, + }); + } + + return { previousProfile }; + }, + onError: (error, _newData, context) => { + // Rollback on error + if (context?.previousProfile) { + queryClient.setQueryData(queryKeys.userProfile.current(), context.previousProfile); + } + console.error("Failed to update user profile:", error); + }, + onSettled: () => { + // Always refetch after error or success + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.current() }); + }, + }); +} + +/** + * Hook to prefetch user profile (useful for app initialization) + * + * @returns Function to prefetch user profile + */ +export function usePrefetchUserProfile() { + const queryClient = useQueryClient(); + + return () => { + queryClient.prefetchQuery({ + queryKey: queryKeys.userProfile.current(), + queryFn: () => CalComAPIService.getUserProfile(), + staleTime: CACHE_CONFIG.userProfile.staleTime, + }); + }; +} + +/** + * Hook to invalidate user profile cache + * + * @returns Function to invalidate user profile cache + */ +export function useInvalidateUserProfile() { + const queryClient = useQueryClient(); + + return () => { + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }); + }; +} + +/** + * Type exports for consumers + */ +export type { UserProfile }; diff --git a/companion/lib/queryPersister.ts b/companion/lib/queryPersister.ts new file mode 100644 index 0000000000..ad927df5b9 --- /dev/null +++ b/companion/lib/queryPersister.ts @@ -0,0 +1,11 @@ +/** + * @deprecated Import from '../utils/queryPersister' instead. + * This file is kept for backward compatibility. + */ + +export { + createQueryPersister, + clearQueryCache, + getCacheMetadata, + storage, +} from "../utils/queryPersister"; diff --git a/companion/lib/storage.ts b/companion/lib/storage.ts new file mode 100644 index 0000000000..a8046ae0f6 --- /dev/null +++ b/companion/lib/storage.ts @@ -0,0 +1,11 @@ +/** + * @deprecated Import from '../utils/storage' instead. + * This file is kept for backward compatibility. + */ + +export { + secureStorage, + generalStorage, + isChromeStorageAvailable, + type StorageAdapter, +} from "../utils/storage"; diff --git a/companion/package-lock.json b/companion/package-lock.json index d18a680e91..511318b571 100644 --- a/companion/package-lock.json +++ b/companion/package-lock.json @@ -10,12 +10,17 @@ "dependencies": { "@expo/ui": "^0.2.0-beta.7", "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "^2.1.0", + "@react-native-community/netinfo": "^11.4.1", "@react-native-segmented-control/segmented-control": "^2.5.7", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-query-persist-client": "^5.62.0", "@types/react": "~19.1.10", "@types/react-dom": "~19.1.7", "base64-js": "^1.5.1", "expo": "~54.0.0", "expo-auth-session": "^7.0.9", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.10", "expo-crypto": "^15.0.7", "expo-device": "^8.0.9", @@ -4046,6 +4051,27 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native-community/netinfo": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz", + "integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.59" + } + }, "node_modules/@react-native-segmented-control/segmented-control": { "version": "2.5.7", "resolved": "https://registry.npmjs.org/@react-native-segmented-control/segmented-control/-/segmented-control-2.5.7.tgz", @@ -4800,6 +4826,62 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.91.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.91.11.tgz", + "integrity": "sha512-NNpRGxQY/nVOdzfs5QbevPjGsUVoEiFwqxxaopLyu6todwtDOCfIOfhXSmpMVXBiCxUn7kqaUB1iwaBKqoAVRQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.90.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.90.14.tgz", + "integrity": "sha512-jTGnr/DBlzV/UYqU+b8bZWECBuqh3Q3g7Ih50IktZkvmwTUsidQhhl2JyknNYVZkl5AgMfPAywNKZLTI82wmlA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.91.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.12", + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7429,6 +7511,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz", + "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.10", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz", @@ -9238,6 +9331,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -10724,6 +10826,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/companion/package.json b/companion/package.json index 4884ea161c..2ad56509d9 100644 --- a/companion/package.json +++ b/companion/package.json @@ -18,12 +18,17 @@ "dependencies": { "@expo/ui": "^0.2.0-beta.7", "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "^2.1.0", + "@react-native-community/netinfo": "^11.4.1", "@react-native-segmented-control/segmented-control": "^2.5.7", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-query-persist-client": "^5.62.0", "@types/react": "~19.1.10", "@types/react-dom": "~19.1.7", "base64-js": "^1.5.1", "expo": "~54.0.0", "expo-auth-session": "^7.0.9", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.10", "expo-crypto": "^15.0.7", "expo-device": "^8.0.9", diff --git a/companion/services/calcom.ts b/companion/services/calcom.ts index 02692a1917..625ace2ee0 100644 --- a/companion/services/calcom.ts +++ b/companion/services/calcom.ts @@ -309,10 +309,8 @@ export class CalComAPIService { return response.json(); } - // Delete an event type static async deleteEventType(eventTypeId: number): Promise { try { - console.log(`Deleting event type with ID: ${eventTypeId}`); await this.makeRequest( `/event-types/${eventTypeId}`, { @@ -320,17 +318,15 @@ export class CalComAPIService { }, "2024-06-14" ); - console.log("Delete completed"); } catch (error) { console.error("Delete API error:", error); throw error; } } - // Create an event type static async createEventType(input: CreateEventTypeInput): Promise { try { - console.log("Creating event type with input:", JSON.stringify(input, null, 2)); + const sanitizedInput = this.sanitizePayload(input as Record); const response = await this.makeRequest<{ status: string; data: EventType }>( "/event-types", @@ -340,13 +336,12 @@ export class CalComAPIService { "Content-Type": "application/json", "cal-api-version": "2024-06-14", }, - body: JSON.stringify(input), + body: JSON.stringify(sanitizedInput), }, "2024-06-14" ); if (response && response.data) { - console.log("Event type created successfully:", response.data); return response.data; } @@ -358,33 +353,38 @@ export class CalComAPIService { } // Cancel a booking - static async cancelBooking(bookingUid: string, reason?: string): Promise { + static async cancelBooking(bookingUid: string, cancellationReason?: string): Promise { try { - const body: { reason?: string } = {}; - if (reason) { - body.reason = reason; + const body: { cancellationReason?: string } = {}; + if (cancellationReason) { + body.cancellationReason = cancellationReason; } - await this.makeRequest(`/bookings/${bookingUid}/cancel`, { - method: "POST", - body: JSON.stringify(body), - }); + await this.makeRequest( + `/bookings/${bookingUid}/cancel`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify(body), + }, + "2024-08-13" + ); } catch (error) { throw error; } } - // Reschedule a booking static async rescheduleBooking( bookingUid: string, input: { - start: string; // ISO 8601 datetime string + start: string; reschedulingReason?: string; } ): Promise { try { - console.log(`Rescheduling booking ${bookingUid} to:`, input.start); - const response = await this.makeRequest<{ status: string; data: Booking }>( `/bookings/${bookingUid}/reschedule`, { @@ -399,7 +399,6 @@ export class CalComAPIService { ); if (response && response.data) { - console.log("Booking rescheduled successfully:", response.data); return response.data; } @@ -410,6 +409,62 @@ export class CalComAPIService { } } + static async confirmBooking(bookingUid: string): Promise { + try { + const response = await this.makeRequest<{ status: string; data: Booking }>( + `/bookings/${bookingUid}/confirm`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + }, + "2024-08-13" + ); + + if (response && response.data) { + return response.data; + } + + throw new Error("Invalid response from confirm booking API"); + } catch (error) { + console.error("confirmBooking error:", error); + throw error; + } + } + + static async declineBooking(bookingUid: string, reason?: string): Promise { + try { + const body: { reason?: string } = {}; + if (reason) { + body.reason = reason; + } + + const response = await this.makeRequest<{ status: string; data: Booking }>( + `/bookings/${bookingUid}/decline`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "cal-api-version": "2024-08-13", + }, + body: JSON.stringify(body), + }, + "2024-08-13" + ); + + if (response && response.data) { + return response.data; + } + + throw new Error("Invalid response from decline booking API"); + } catch (error) { + console.error("declineBooking error:", error); + throw error; + } + } + static async getEventTypes(): Promise { try { // Get current user to extract username @@ -422,11 +477,15 @@ export class CalComAPIService { } } catch (error) {} - // Build query string with username if available + // Build query string with username and sorting const params = new URLSearchParams(); if (username) { params.append("username", username); } + // Sort by creation date descending (newer first) to match main codebase behavior + // Main codebase uses position: "desc", id: "desc" - since API doesn't expose position, + // we use sortCreatedAt: "desc" for similar behavior (newer event types first) + params.append("sortCreatedAt", "desc"); const queryString = params.toString(); const endpoint = `/event-types${queryString ? `?${queryString}` : ""}`; @@ -483,12 +542,7 @@ export class CalComAPIService { }, "2024-08-13" ); - console.log("getBookingByUid raw response:", JSON.stringify(response, null, 2)); if (response && response.data) { - console.log("getBookingByUid booking data:", JSON.stringify(response.data, null, 2)); - console.log("getBookingByUid user field:", response.data.user); - console.log("getBookingByUid hosts field:", response.data.hosts); - console.log("getBookingByUid attendees field:", response.data.attendees); return response.data; } throw new Error("Invalid response from get booking API"); @@ -658,7 +712,7 @@ export class CalComAPIService { }>; }): Promise { try { - console.log("Creating schedule with input:", JSON.stringify(input, null, 2)); + const sanitizedInput = this.sanitizePayload(input as Record); const response = await this.makeRequest<{ status: string; data: Schedule }>( "/schedules", @@ -668,13 +722,12 @@ export class CalComAPIService { "Content-Type": "application/json", "cal-api-version": "2024-06-11", }, - body: JSON.stringify(input), + body: JSON.stringify(sanitizedInput), }, "2024-06-11" ); if (response && response.data) { - console.log("Schedule created successfully:", response.data); return response.data; } @@ -685,33 +738,26 @@ export class CalComAPIService { } } - // Get specific schedule by ID static async getScheduleById(scheduleId: number): Promise { try { const response = await this.makeRequest( `/schedules/${scheduleId}`, { headers: { - "cal-api-version": "2024-06-11", // Override version for schedules + "cal-api-version": "2024-06-11", }, }, "2024-06-11" ); - console.log("getScheduleById raw response:", JSON.stringify(response, null, 2)); - if (response && response.data) { - console.log("Returning schedule data:", response.data); return response.data; } - // Sometimes the response might be the schedule directly if (response && response.id) { - console.log("Returning schedule directly:", response); return response; } - console.log("No schedule data found in response"); return null; } catch (error) { console.error("getScheduleById error:", error); @@ -767,12 +813,77 @@ export class CalComAPIService { } // Update an event type + /** + * Sanitizes a payload before sending to the API. + * - Removes keys with null values for array fields (API expects arrays or field to be omitted) + * - Removes keys with undefined values + * - Recursively sanitizes nested objects + */ + private static sanitizePayload(payload: Record): Record { + const sanitized: Record = {}; + + // Fields that should NEVER be sent as null - API expects array or omit entirely + const arrayFields = [ + "lengthInMinutesOptions", + "multipleDuration", + "locations", + "bookingFields", + "hosts", + "children", + "customInputs", + ]; + + // Fields that can be null (to clear the value) + const nullableFields = [ + "description", + "successRedirectUrl", + "slotInterval", + "eventName", + "timeZone", + ]; + + for (const [key, value] of Object.entries(payload)) { + // Skip undefined values + if (value === undefined) continue; + + // Handle null values + if (value === null) { + // For array fields, skip entirely (don't send null) + if (arrayFields.includes(key)) { + console.warn(`Skipping null value for array field: ${key}`); + continue; + } + // For nullable fields, allow null + if (nullableFields.includes(key)) { + sanitized[key] = null; + continue; + } + // For other fields, skip null to be safe + console.warn(`Skipping null value for field: ${key}`); + continue; + } + + // Recursively sanitize nested objects (but not arrays) + if (typeof value === "object" && !Array.isArray(value)) { + const sanitizedNested = this.sanitizePayload(value); + // Only include if the nested object has values + if (Object.keys(sanitizedNested).length > 0) { + sanitized[key] = sanitizedNested; + } + } else { + sanitized[key] = value; + } + } + + return sanitized; + } + static async updateEventType( eventTypeId: number, updates: Partial ): Promise { try { - console.log(`Updating event type ${eventTypeId} with:`, JSON.stringify(updates, null, 2)); + const sanitizedUpdates = this.sanitizePayload(updates as Record); const response = await this.makeRequest<{ status: string; data: EventType }>( `/event-types/${eventTypeId}`, @@ -782,13 +893,12 @@ export class CalComAPIService { "Content-Type": "application/json", "cal-api-version": "2024-06-14", }, - body: JSON.stringify(updates), + body: JSON.stringify(sanitizedUpdates), }, "2024-06-14" ); if (response && response.data) { - console.log("Event type updated successfully:", response.data); return response.data; } @@ -819,6 +929,8 @@ export class CalComAPIService { } ): Promise { try { + // Sanitize the updates to remove null values + const sanitizedUpdates = this.sanitizePayload(updates as Record); const response = await this.makeRequest<{ status: string; data: Schedule }>( `/schedules/${scheduleId}`, { @@ -827,7 +939,7 @@ export class CalComAPIService { "Content-Type": "application/json", "cal-api-version": "2024-06-11", }, - body: JSON.stringify(updates), + body: JSON.stringify(sanitizedUpdates), }, "2024-06-11" ); @@ -910,12 +1022,13 @@ export class CalComAPIService { // Create a global webhook static async createWebhook(input: CreateWebhookInput): Promise { try { + const sanitizedInput = this.sanitizePayload(input as Record); const response = await this.makeRequest<{ status: string; data: Webhook }>("/webhooks", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(input), + body: JSON.stringify(sanitizedInput), }); if (response && response.data) { @@ -932,6 +1045,7 @@ export class CalComAPIService { // Update a global webhook static async updateWebhook(webhookId: string, updates: UpdateWebhookInput): Promise { try { + const sanitizedUpdates = this.sanitizePayload(updates as Record); const response = await this.makeRequest<{ status: string; data: Webhook }>( `/webhooks/${webhookId}`, { @@ -939,7 +1053,7 @@ export class CalComAPIService { headers: { "Content-Type": "application/json", }, - body: JSON.stringify(updates), + body: JSON.stringify(sanitizedUpdates), } ); @@ -990,6 +1104,7 @@ export class CalComAPIService { input: CreateWebhookInput ): Promise { try { + const sanitizedInput = this.sanitizePayload(input as Record); const response = await this.makeRequest<{ status: string; data: Webhook }>( `/event-types/${eventTypeId}/webhooks`, { @@ -997,7 +1112,7 @@ export class CalComAPIService { headers: { "Content-Type": "application/json", }, - body: JSON.stringify(input), + body: JSON.stringify(sanitizedInput), } ); @@ -1019,6 +1134,7 @@ export class CalComAPIService { updates: UpdateWebhookInput ): Promise { try { + const sanitizedUpdates = this.sanitizePayload(updates as Record); const response = await this.makeRequest<{ status: string; data: Webhook }>( `/event-types/${eventTypeId}/webhooks/${webhookId}`, { @@ -1026,7 +1142,7 @@ export class CalComAPIService { headers: { "Content-Type": "application/json", }, - body: JSON.stringify(updates), + body: JSON.stringify(sanitizedUpdates), } ); @@ -1081,6 +1197,7 @@ export class CalComAPIService { input: CreatePrivateLinkInput = {} ): Promise { try { + const sanitizedInput = this.sanitizePayload(input as Record); const response = await this.makeRequest<{ status: string; data: PrivateLink }>( `/event-types/${eventTypeId}/private-links`, { @@ -1088,7 +1205,7 @@ export class CalComAPIService { headers: { "Content-Type": "application/json", }, - body: JSON.stringify(input), + body: JSON.stringify(sanitizedInput), } ); @@ -1110,6 +1227,7 @@ export class CalComAPIService { updates: UpdatePrivateLinkInput ): Promise { try { + const sanitizedUpdates = this.sanitizePayload(updates as Record); const response = await this.makeRequest<{ status: string; data: PrivateLink }>( `/event-types/${eventTypeId}/private-links/${linkId}`, { @@ -1117,7 +1235,7 @@ export class CalComAPIService { headers: { "Content-Type": "application/json", }, - body: JSON.stringify(updates), + body: JSON.stringify(sanitizedUpdates), } ); diff --git a/companion/services/oauthService.ts b/companion/services/oauthService.ts index c99bdb023c..28db8bb6ed 100644 --- a/companion/services/oauthService.ts +++ b/companion/services/oauthService.ts @@ -5,7 +5,6 @@ import * as Crypto from "expo-crypto"; import * as WebBrowser from "expo-web-browser"; import { Platform } from "react-native"; -// Complete warm up for WebBrowser on mobile WebBrowser.maybeCompleteAuthSession(); export interface OAuthTokens { @@ -31,11 +30,7 @@ export class CalComOAuthService { this.config = config; } - private async generatePKCEParams(): Promise<{ - codeVerifier: string; - codeChallenge: string; - state: string; - }> { + private async generatePKCEParams() { const codeVerifier = this.generateRandomBase64Url(); const codeChallenge = await this.generateCodeChallenge(codeVerifier); const state = this.generateRandomBase64Url(); @@ -47,18 +42,13 @@ export class CalComOAuthService { } private async generateCodeChallenge(codeVerifier: string): Promise { - try { - const base64Hash = await Crypto.digestStringAsync( - Crypto.CryptoDigestAlgorithm.SHA256, - codeVerifier, - { encoding: Crypto.CryptoEncoding.BASE64 } - ); + const base64Hash = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + codeVerifier, + { encoding: Crypto.CryptoEncoding.BASE64 } + ); - return base64Hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); - } catch (error) { - console.error("Failed to generate code challenge:", error); - throw new Error("Failed to generate OAuth code challenge"); - } + return base64Hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } private generateRandomBase64Url(): string { @@ -75,7 +65,7 @@ export class CalComOAuthService { client_id: this.config.clientId, response_type: "code", redirect_uri: this.config.redirectUri, - state: state, + state, code_challenge: codeChallenge, code_challenge_method: "S256", }); @@ -83,338 +73,147 @@ export class CalComOAuthService { return `${this.config.calcomBaseUrl}/auth/oauth2/authorize?${params.toString()}`; } - async startAuthorizationFlow(): Promise { - try { - const { codeChallenge, state } = await this.generatePKCEParams(); - - // Web only: check for stored callback with state-specific keys to prevent race conditions - if (Platform.OS === "web" && typeof window !== "undefined") { - const storedCode = window.localStorage.getItem(`oauth_callback_code_${state}`); - const storedState = window.localStorage.getItem(`oauth_callback_state_${state}`); - - if (storedCode && storedState) { - // CSRF protection: verify state matches - if (storedState !== state) { - window.localStorage.removeItem(`oauth_callback_code_${state}`); - window.localStorage.removeItem(`oauth_callback_state_${state}`); - throw new Error("Invalid state parameter - possible CSRF attack"); - } - - window.localStorage.removeItem(`oauth_callback_code_${state}`); - window.localStorage.removeItem(`oauth_callback_state_${state}`); - - return await this.exchangeCodeForTokens(storedCode, state); - } - } - - // Note: State stored in background script (iframe may not have chrome.storage access) - - const authResult = await this.getAuthorizationResult(codeChallenge, state); - if (authResult.type === "success") { - const code = authResult.params?.code || authResult.params?.authorizationCode; - const returnedState = authResult.params?.state; - - if (returnedState !== state) { - throw new Error("Invalid state parameter - possible CSRF attack"); - } - - if (!code) { - throw new Error("No authorization code received"); - } - - return await this.exchangeCodeForTokens(code, state); - } - - if (authResult.type === "error") { - const errorDescription = - authResult.params?.error_description || - ("error" in authResult ? authResult.error?.message : undefined) || - "Unknown error"; - throw new Error(`OAuth error: ${errorDescription}`); - } - - if (authResult.type === "cancel") { - throw new Error("OAuth flow was cancelled by user"); - } - - throw new Error("OAuth flow failed or was dismissed"); - } catch (error) { - console.error("OAuth authorization error:", error); - throw error; - } - } - - // Detect if this is a mobile app (not web/extension) private isMobileApp(): boolean { return Platform.OS !== "web"; } - private async launchExtensionAuthFlow(authUrl: string): Promise { - return new Promise((resolve, reject) => { - if (typeof chrome !== "undefined" && chrome.identity) { - chrome.identity.launchWebAuthFlow( - { - url: authUrl, - interactive: true, - }, - (responseUrl) => { - if (chrome.runtime.lastError) { - reject(new Error(`OAuth flow failed: ${chrome.runtime.lastError.message}`)); - } else if (responseUrl) { - resolve(responseUrl); - } else { - reject(new Error("OAuth flow cancelled or failed")); - } - } - ); - return; + async startAuthorizationFlow(): Promise { + const { codeChallenge, state } = await this.generatePKCEParams(); + + if (Platform.OS === "web" && typeof window !== "undefined") { + const storedCode = window.localStorage.getItem(`oauth_callback_code_${state}`); + const storedState = window.localStorage.getItem(`oauth_callback_state_${state}`); + + if (storedCode && storedState) { + if (storedState !== state) { + throw new Error("Invalid state parameter"); + } + + window.localStorage.removeItem(`oauth_callback_code_${state}`); + window.localStorage.removeItem(`oauth_callback_state_${state}`); + + return this.exchangeCodeForTokens(storedCode, state); } + } - // Iframe: communicate with parent window - if (window.parent !== window) { - let timeoutId: NodeJS.Timeout | null = null; + const result = await this.getAuthorizationResult(codeChallenge, state); - const messageHandler = (event: MessageEvent) => { - // Security: only accept messages from parent - if (event.source !== window.parent) { - return; - } + if (result.type !== "success") { + throw new Error("OAuth flow failed"); + } - if (event.data.type === "cal-extension-oauth-result") { - window.removeEventListener("message", messageHandler); - if (timeoutId) { - clearTimeout(timeoutId); - } + const code = result.params.code; + const returnedState = result.params.state; - if (event.data.success) { - resolve(event.data.responseUrl); - } else { - console.error("OAuth flow failed:", event.data.error); - reject(new Error(event.data.error || "OAuth flow failed")); - } - } - }; + if (!code) { + throw new Error("No authorization code received"); + } - window.addEventListener("message", messageHandler); + if (returnedState !== state) { + throw new Error("Invalid state parameter"); + } - window.parent.postMessage( - { - type: "cal-extension-oauth-request", - authUrl: authUrl, - }, - "*" - ); - - timeoutId = setTimeout(() => { - console.error("OAuth flow timeout - no response from extension"); - window.removeEventListener("message", messageHandler); - reject(new Error("OAuth flow timeout - no response from extension")); - }, 30000); - } else { - reject(new Error("Chrome extension context not detected")); - } - }); + return this.exchangeCodeForTokens(code, state); } private async getAuthorizationResult( codeChallenge: string, state: string - ): Promise }> { + ): Promise<{ type: "success"; params: Record } | { type: "error" }> { const authUrl = this.buildAuthorizationUrl(codeChallenge, state); if (this.isMobileApp()) { - const result = await WebBrowser.openAuthSessionAsync(authUrl, this.config.redirectUri); + const result = await WebBrowser.openAuthSessionAsync(authUrl, this.config.redirectUri, { + preferEphemeralSession: false, + }); if (result.type === "success") { - const params = this.parseCallbackUrl(result.url); - return { type: "success" as const, params }; + return { type: "success", params: this.parseCallbackUrl(result.url) }; } - return { type: result.type, params: {} } as { type: string; params: Record }; - } else { - // Treat everything else as browser extension - try { - const responseUrl = await this.launchExtensionAuthFlow(authUrl); - const params = this.parseCallbackUrl(responseUrl); - return { type: "success" as const, params }; - } catch (error) { - console.error("Extension OAuth flow failed:", error); - return { type: "error", params: { error: error.message } } as { - type: string; - params: Record; - }; + return { type: "error" }; + } + + if (Platform.OS === "web") { + const discovery = await this.getDiscoveryEndpoints(); + const request = new AuthSession.AuthRequest({ + clientId: this.config.clientId, + redirectUri: this.config.redirectUri, + responseType: AuthSession.ResponseType.Code, + state, + codeChallenge, + codeChallengeMethod: AuthSession.CodeChallengeMethod.S256, + }); + + const result = await request.promptAsync(discovery); + + if (result.type === "success") { + return { type: "success", params: result.params ?? {} }; } + + return { type: "error" }; + } + + try { + const responseUrl = await this.launchExtensionAuthFlow(authUrl); + return { type: "success", params: this.parseCallbackUrl(responseUrl) }; + } catch { + return { type: "error" }; } } + private async launchExtensionAuthFlow(authUrl: string): Promise { + return new Promise((resolve, reject) => { + if (typeof chrome !== "undefined" && chrome.identity) { + chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else if (responseUrl) { + resolve(responseUrl); + } else { + reject(new Error("OAuth cancelled")); + } + }); + return; + } + + reject(new Error("Extension context not available")); + }); + } + private async getDiscoveryEndpoints(): Promise { - const fallbackDiscovery: AuthSession.DiscoveryDocument = { + return { authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`, tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`, revocationEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/revoke`, }; - - const isCrossOriginWeb = - Platform.OS === "web" && - typeof window !== "undefined" && - (() => { - try { - return new URL(this.config.calcomBaseUrl).origin !== window.location.origin; - } catch { - return true; - } - })(); - - // Skip discovery fetch when we know CORS will block it (e.g. companion.cal.com -> app.cal.com). - if (isCrossOriginWeb) { - return fallbackDiscovery; - } - - try { - const discovery = await AuthSession.fetchDiscoveryAsync(this.config.calcomBaseUrl); - return { - ...fallbackDiscovery, - ...discovery, - }; - } catch (error) { - console.warn("Failed to load discovery document, using fallback endpoints", error); - return fallbackDiscovery; - } } private parseCallbackUrl(url: string): Record { - const urlObj = new URL(url); + const parsed = new URL(url); const params: Record = {}; - urlObj.searchParams.forEach((value, key) => { - params[key] = value; + parsed.searchParams.forEach((v, k) => { + params[k] = v; }); - if (Object.keys(params).length === 0 && urlObj.hash) { - const hashParams = new URLSearchParams(urlObj.hash.substring(1)); - hashParams.forEach((value, key) => { - params[key] = value; - }); - } - return params; } - private async exchangeCodeForTokens(code: string, state?: string): Promise { + private async exchangeCodeForTokens(code: string): Promise { if (!this.codeVerifier) { - throw new Error("No code verifier available"); + throw new Error("Missing code verifier"); } - // Extension: use APIs to avoid CORS - if (!this.isMobileApp() && typeof window !== "undefined" && window.parent !== window) { - return await this.exchangeTokensViaExtension(code, state); - } - - const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/token`; - - const body = new URLSearchParams(); - body.append("grant_type", "authorization_code"); - body.append("client_id", this.config.clientId); - body.append("code", code); - body.append("redirect_uri", this.config.redirectUri); - body.append("code_verifier", this.codeVerifier); - - const response = await fetch(tokenEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: body.toString(), - }); - - if (!response.ok) { - const errorData = await response.text(); - console.error("Token exchange error response:", errorData); - - try { - const errorJson = JSON.parse(errorData); - console.error("Parsed error:", errorJson); - } catch { - console.error("Could not parse error response as JSON"); - } - - throw new Error(`Token exchange failed: ${response.status} ${errorData}`); - } - - const tokenData = await response.json(); - - const tokens: OAuthTokens = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - tokenType: tokenData.token_type || "Bearer", - expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined, - scope: tokenData.scope, - }; - - return tokens; - } - - private async exchangeTokensViaExtension(code: string, state?: string): Promise { - return new Promise((resolve, reject) => { - let timeoutId: NodeJS.Timeout | null = null; - - const messageHandler = (event: MessageEvent) => { - // Security: only accept messages from parent - if (event.source !== window.parent) { - return; - } - - if (event.data.type === "cal-extension-token-exchange-result") { - window.removeEventListener("message", messageHandler); - if (timeoutId) { - clearTimeout(timeoutId); - } - - if (event.data.success) { - resolve(event.data.tokens); - } else { - console.error("Token exchange failed via extension:", event.data.error); - reject(new Error(event.data.error || "Token exchange failed")); - } - } - }; - - window.addEventListener("message", messageHandler); - - window.parent.postMessage( - { - type: "cal-extension-token-exchange-request", - tokenRequest: { - grant_type: "authorization_code", - client_id: this.config.clientId, - code: code, - redirect_uri: this.config.redirectUri, - code_verifier: this.codeVerifier, - }, - state: state, // CSRF validation in background script - tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`, - }, - "*" - ); - - timeoutId = setTimeout(() => { - window.removeEventListener("message", messageHandler); - reject(new Error("Token exchange timeout")); - }, 30000); - }); - } - - async refreshAccessToken(refreshToken: string): Promise { - const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/refreshToken`; - const body = new URLSearchParams({ - grant_type: "refresh_token", + grant_type: "authorization_code", client_id: this.config.clientId, - refresh_token: refreshToken, + code, + redirect_uri: this.config.redirectUri, + code_verifier: this.codeVerifier, }); - const response = await fetch(tokenEndpoint, { + const response = await fetch(`${this.config.calcomBaseUrl}/api/auth/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -424,51 +223,42 @@ export class CalComOAuthService { }); if (!response.ok) { - const errorData = await response.text(); - throw new Error(`Token refresh failed: ${response.status} ${errorData}`); + throw new Error("Token exchange failed"); } - const tokenData = await response.json(); + const data = await response.json(); - const tokens: OAuthTokens = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token || refreshToken, - tokenType: tokenData.token_type || "Bearer", - expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined, - scope: tokenData.scope, + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + tokenType: data.token_type ?? "Bearer", + expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, + scope: data.scope, }; - - return tokens; } isTokenExpired(tokens: OAuthTokens): boolean { - if (!tokens.expiresAt) { - return false; - } - // 5-minute buffer before expiry + if (!tokens.expiresAt) return false; return Date.now() >= tokens.expiresAt - 5 * 60 * 1000; } - clearPKCEParams(): void { + + clearPKCEParams() { this.codeVerifier = null; this.state = null; } } export function createCalComOAuthService(overrides: Partial = {}): CalComOAuthService { - let defaultRedirectUri: string; - - const defaultConfig: OAuthConfig = { + const config: OAuthConfig = { clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "", - redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI, + redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "", calcomBaseUrl: "https://app.cal.com", ...overrides, }; - if (!defaultConfig.clientId) { - throw new Error( - "OAuth client ID is required. Set EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID environment variable." - ); + if (!config.clientId || !config.redirectUri) { + throw new Error("OAuth configuration incomplete"); } - return new CalComOAuthService(defaultConfig); + return new CalComOAuthService(config); } diff --git a/companion/types/index.ts b/companion/types/index.ts new file mode 100644 index 0000000000..a34ce07c1a --- /dev/null +++ b/companion/types/index.ts @@ -0,0 +1,6 @@ +/** + * Central export for all custom types + */ + +export * from "./locations"; + diff --git a/companion/types/locations.ts b/companion/types/locations.ts new file mode 100644 index 0000000000..cba406bc37 --- /dev/null +++ b/companion/types/locations.ts @@ -0,0 +1,94 @@ +/** + * Location types for event type management + * These types are used across the companion app for handling event type locations + */ + +/** + * Valid location types supported by the Cal.com API + */ +export type LocationType = + | "integration" + | "address" + | "link" + | "phone" + | "attendeeAddress" + | "attendeePhone" + | "attendeeDefined"; + +/** + * Valid integration types for conferencing apps + */ +export type IntegrationType = + | "cal-video" + | "google-meet" + | "zoom" + | "office365-video" + | "msteams" + | "webex" + | "jitsi"; + +/** + * Represents a location item in the UI + */ +export interface LocationItem { + /** Unique identifier for the location (used for list rendering) */ + id: string; + /** The type of location */ + type: LocationType; + /** Integration app ID (for conferencing apps) */ + integration?: string; + /** Physical address (for address type) */ + address?: string; + /** Meeting link URL (for link type) */ + link?: string; + /** Phone number (for phone types) */ + phone?: string; + /** Whether the location is public */ + public?: boolean; + /** Display name shown in the UI */ + displayName: string; + /** Icon URL for the location */ + iconUrl: string | null; +} + +/** + * Location object as returned from the API + */ +export interface ApiLocation { + type: string; + integration?: string; + address?: string; + link?: string; + phone?: string; + public?: boolean; +} + +/** + * Location input for API requests + */ +export interface ApiLocationInput { + type: string; + integration?: string; + address?: string; + link?: string; + phone?: string; + public?: boolean; +} + +/** + * Location option for dropdown selection + */ +export interface LocationOption { + label: string; + value: string; + iconUrl: string | null; + category?: string; +} + +/** + * Grouped location options for dropdown + */ +export interface LocationOptionGroup { + category: string; + options: LocationOption[]; +} diff --git a/companion/utils/alerts.ts b/companion/utils/alerts.ts new file mode 100644 index 0000000000..4ac6c724a9 --- /dev/null +++ b/companion/utils/alerts.ts @@ -0,0 +1,39 @@ +/** + * Alert Utilities + * + * Helper functions for showing alerts with environment-aware behavior. + * Error alerts are only shown in development mode to avoid confusing users in production. + */ + +import { Alert } from "react-native"; + +/** + * Show an error alert only in development mode. + * In production, errors are silently logged to console. + * + * @param title - The alert title + * @param message - The error message to display + */ +export const showErrorAlert = (title: string, message: string) => { + if (__DEV__) { + Alert.alert(title, message); + } else { + console.error(`[${title}] ${message}`); + } +}; + +/** + * Show a confirmation alert (always shown - user-initiated actions) + * This is a direct reference to Alert.alert for consistency. + */ +export const showConfirmAlert = Alert.alert; + +/** + * Show a success alert (always shown - user feedback) + * + * @param title - The alert title + * @param message - The success message to display + */ +export const showSuccessAlert = (title: string, message: string) => { + Alert.alert(title, message); +}; diff --git a/companion/utils/browser.ts b/companion/utils/browser.ts new file mode 100644 index 0000000000..33d0981783 --- /dev/null +++ b/companion/utils/browser.ts @@ -0,0 +1,63 @@ +/** + * Browser Utilities + * + * Centralized utility for opening links in the in-app browser. + * Configured for session sharing with Safari/Chrome to maintain login state. + */ + +import * as WebBrowser from "expo-web-browser"; +import { showErrorAlert } from "./alerts"; + +/** + * Configuration options for in-app browser + */ +export interface BrowserOptions { + /** iOS: Toolbar color (hex string) */ + toolbarColor?: string; + /** iOS: Controls color (hex string) */ + controlsColor?: string; +} + +/** + * Open a URL in the in-app browser with session sharing enabled. + * + * Session sharing allows cookies to be shared between the in-app browser + * and Safari (iOS) or Chrome (Android). This means users who authenticate + * via OAuth will remain logged in when opening Cal.com links. + * + * @param url - The URL to open + * @param fallbackMessage - Optional message to show in error alert (defaults to "link") + * @param options - Optional browser customization options + * + * @example + * ```tsx + * // Open a link with session sharing + * await openInAppBrowser("https://app.cal.com"); + * + * // With custom error message + * await openInAppBrowser("https://app.cal.com/settings", "Settings page"); + * + * // With custom toolbar color + * await openInAppBrowser("https://app.cal.com", "Cal.com", { toolbarColor: "#111827" }); + * ``` + */ +export const openInAppBrowser = async ( + url: string, + fallbackMessage?: string, + options?: BrowserOptions +): Promise => { + try { + // Configure browser options + // Session sharing happens automatically when using Safari View Controller (iOS) + // or Chrome Custom Tabs (Android) - no special configuration needed + const browserOptions: WebBrowser.WebBrowserOpenOptions = { + ...(options?.toolbarColor && { toolbarColor: options.toolbarColor }), + ...(options?.controlsColor && { controlsColor: options.controlsColor }), + }; + + await WebBrowser.openBrowserAsync(url, browserOptions); + } catch (error) { + console.error(`Failed to open ${url}:`, error); + showErrorAlert("Error", `Failed to open ${fallbackMessage || "link"}. Please try again.`); + } +}; diff --git a/companion/utils/parsers/event-type-parsers.ts b/companion/utils/eventTypeParsers.ts similarity index 57% rename from companion/utils/parsers/event-type-parsers.ts rename to companion/utils/eventTypeParsers.ts index 61d30a6e05..e83882b5d7 100644 --- a/companion/utils/parsers/event-type-parsers.ts +++ b/companion/utils/eventTypeParsers.ts @@ -1,12 +1,24 @@ /** * Helper functions to parse form values into API-compatible formats + * Used for event type form handling */ +/** + * Parse buffer time string to minutes + * @param buffer - Buffer time string (e.g., "15 minutes", "30") + * @returns Number of minutes + */ export const parseBufferTime = (buffer: string): number => { const match = buffer.match(/(\d+)/); return match ? parseInt(match[1]) : 0; }; +/** + * Parse minimum notice value and unit to total minutes + * @param value - Numeric value as string + * @param unit - Unit ("Minutes", "Hours", "Days") + * @returns Total minutes + */ export const parseMinimumNotice = (value: string, unit: string): number => { const val = parseInt(value) || 0; if (unit === "Hours") return val * 60; @@ -14,6 +26,11 @@ export const parseMinimumNotice = (value: string, unit: string): number => { return val; // Minutes }; +/** + * Parse frequency unit string to API format + * @param unit - Unit string (e.g., "Weeks", "Monthly") + * @returns Normalized unit ("day", "week", "month", "year") or null + */ export const parseFrequencyUnit = (unit: string): string | null => { const normalized = unit.toLowerCase(); if (normalized.includes("day")) return "day"; @@ -23,6 +40,11 @@ export const parseFrequencyUnit = (unit: string): string | null => { return null; }; +/** + * Parse slot interval string to minutes + * @param interval - Interval string (e.g., "15 minutes", "30") + * @returns Number of minutes + */ export const parseSlotInterval = (interval: string): number => { const match = interval.match(/(\d+)/); return match ? parseInt(match[1]) : 0; diff --git a/companion/utils/formatters.ts b/companion/utils/formatters.ts new file mode 100644 index 0000000000..020a787cb2 --- /dev/null +++ b/companion/utils/formatters.ts @@ -0,0 +1,54 @@ +/** + * Generic formatting utilities + * These are reusable formatting functions used across the app + */ + +/** + * Format duration in minutes to a human-readable string + * @param minutes - Duration in minutes (number or string) + * @returns Formatted string like "30m", "1h", "1h 30m" + * + * @example + * formatDuration(30) // "30m" + * formatDuration(60) // "1h" + * formatDuration(90) // "1h 30m" + */ +export const formatDuration = (minutes: number | string | undefined): string => { + const mins = typeof minutes === "string" ? parseInt(minutes) || 0 : minutes || 0; + if (mins <= 0) return "0m"; + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + const remainingMins = mins % 60; + return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`; +}; + +/** + * Truncate text to a maximum length with ellipsis + * @param text - The text to truncate + * @param maxLength - Maximum length (default: 20) + * @returns Truncated text with "..." if it exceeds maxLength + * + * @example + * truncateTitle("Very long title here", 10) // "Very long..." + */ +export const truncateTitle = (text: string, maxLength: number = 20): string => { + return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; +}; + +/** + * Format an app ID to a display name + * Converts kebab-case to Title Case + * + * @param appId - The app identifier (e.g., "google-meet", "cal-video") + * @returns Formatted display name (e.g., "Google Meet", "Cal Video") + * + * @example + * formatAppIdToDisplayName("google-meet") // "Google Meet" + * formatAppIdToDisplayName("cal-video") // "Cal Video" + */ +export const formatAppIdToDisplayName = (appId: string): string => { + return appId + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}; diff --git a/companion/utils/gmail-google-chip-parser.ts b/companion/utils/gmailGoogleChipParser.ts similarity index 100% rename from companion/utils/gmail-google-chip-parser.ts rename to companion/utils/gmailGoogleChipParser.ts diff --git a/companion/utils/index.ts b/companion/utils/index.ts new file mode 100644 index 0000000000..2376dad4fb --- /dev/null +++ b/companion/utils/index.ts @@ -0,0 +1,82 @@ +/** + * Utils Index + * + * Central export point for all utility functions. + * Import utilities from this file for clean imports: + * + * @example + * ```tsx + * import { formatDuration, secureStorage, parseBufferTime } from '../utils'; + * ``` + */ + +// Formatting utilities +export { formatDuration, truncateTitle, formatAppIdToDisplayName } from "./formatters"; + +// Storage utilities +export { + secureStorage, + generalStorage, + isChromeStorageAvailable, + type StorageAdapter, +} from "./storage"; + +// Query persistence utilities +export { createQueryPersister, clearQueryCache, getCacheMetadata } from "./queryPersister"; + +// Alert utilities +export { showErrorAlert } from "./alerts"; + +// Browser utilities +export { openInAppBrowser } from "./browser"; + +// Network utilities +export { isOnline, subscribeToNetworkChanges } from "./network"; + +// Slug utilities +export { slugify } from "./slugify"; + +// App icon utilities +export { getAppIconUrl } from "./getAppIconUrl"; + +// Default location utilities +export { + defaultLocations, + getDefaultLocationIconUrl, + isDefaultLocation, + DefaultLocationType, + type DefaultLocation, +} from "./defaultLocations"; + +// Location helper utilities +export { + formatAppIdToDisplayName as formatAppId, // Alias for backward compatibility + generateLocationId, + mapApiLocationToItem, + mapItemToApiLocation, + getLocationDisplayName, + getLocationIconUrl, + createLocationItemFromOption, + locationRequiresInput, + getLocationInputType, + getLocationInputPlaceholder, + getLocationInputLabel, + validateLocationItem, + buildLocationOptions, + displayNameToLocationValue, +} from "./locationHelpers"; + +// Event type parser utilities +export { + parseBufferTime, + parseMinimumNotice, + parseFrequencyUnit, + parseSlotInterval, +} from "./eventTypeParsers"; + +// Gmail Google Chip parser (for extension) +export { + parseGoogleChip, + type GoogleTimeSlot, + type ParsedGoogleChip, +} from "./gmailGoogleChipParser"; diff --git a/companion/utils/locationHelpers.ts b/companion/utils/locationHelpers.ts new file mode 100644 index 0000000000..0f9779bee6 --- /dev/null +++ b/companion/utils/locationHelpers.ts @@ -0,0 +1,516 @@ +/** + * Helper functions for handling event type locations + * Provides utilities for converting between API format and UI format + */ + +import { getAppIconUrl } from "./getAppIconUrl"; +import { + defaultLocations, + getDefaultLocationIconUrl, + DefaultLocationType, +} from "./defaultLocations"; +import { formatAppIdToDisplayName } from "./formatters"; +import { + LocationItem, + ApiLocation, + ApiLocationInput, + LocationOption, + LocationOptionGroup, +} from "../types/locations"; + +// Re-export formatAppIdToDisplayName for backward compatibility +export { formatAppIdToDisplayName } from "./formatters"; + +/** + * Generate a unique ID for a location item + */ +export function generateLocationId(): string { + return `loc_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Map an API location response to a UI LocationItem + */ +export function mapApiLocationToItem(apiLocation: ApiLocation): LocationItem { + const id = generateLocationId(); + + // Handle integration type (conferencing apps) + if (apiLocation.type === "integration" && apiLocation.integration) { + const iconUrl = getAppIconUrl("", apiLocation.integration); + return { + id, + type: "integration", + integration: apiLocation.integration, + displayName: formatAppIdToDisplayName(apiLocation.integration), + iconUrl, + public: apiLocation.public, + }; + } + + // Handle address type + if (apiLocation.type === "address") { + return { + id, + type: "address", + address: apiLocation.address || "", + displayName: "In Person (Organizer Address)", + iconUrl: "https://app.cal.com/map-pin-dark.svg", + public: apiLocation.public, + }; + } + + // Handle attendeeAddress type + if (apiLocation.type === "attendeeAddress") { + return { + id, + type: "attendeeAddress", + displayName: "In Person (Attendee Address)", + iconUrl: "https://app.cal.com/map-pin-dark.svg", + }; + } + + // Handle link type + if (apiLocation.type === "link") { + return { + id, + type: "link", + link: apiLocation.link || "", + displayName: "Link Meeting", + iconUrl: "https://app.cal.com/link.svg", + public: apiLocation.public, + }; + } + + // Handle phone type (organizer phone) + if (apiLocation.type === "phone") { + return { + id, + type: "phone", + phone: apiLocation.phone || "", + displayName: "Organizer Phone Number", + iconUrl: "https://app.cal.com/phone.svg", + public: apiLocation.public, + }; + } + + // Handle attendeePhone type + if (apiLocation.type === "attendeePhone") { + return { + id, + type: "attendeePhone", + displayName: "Attendee Phone Number", + iconUrl: "https://app.cal.com/phone.svg", + }; + } + + // Handle attendeeDefined type + if (apiLocation.type === "attendeeDefined") { + return { + id, + type: "attendeeDefined", + displayName: "Custom Attendee Location", + iconUrl: "https://app.cal.com/message-pin.svg", + }; + } + + // Fallback for unknown types + return { + id, + type: apiLocation.type as LocationItem["type"], + displayName: apiLocation.type, + iconUrl: null, + }; +} + +/** + * Map a UI LocationItem to API location format for saving + */ +export function mapItemToApiLocation(item: LocationItem): ApiLocationInput { + // Handle integration type + if (item.type === "integration" && item.integration) { + return { + type: "integration", + integration: item.integration, + }; + } + + // Handle address type + if (item.type === "address") { + return { + type: "address", + address: item.address || "", + public: item.public ?? true, + }; + } + + // Handle attendeeAddress type + if (item.type === "attendeeAddress") { + return { + type: "attendeeAddress", + }; + } + + // Handle link type + if (item.type === "link") { + return { + type: "link", + link: item.link || "", + public: item.public ?? true, + }; + } + + // Handle phone type (organizer phone) + if (item.type === "phone") { + return { + type: "phone", + phone: item.phone || "", + public: item.public ?? true, + }; + } + + // Handle attendeePhone type + if (item.type === "attendeePhone") { + return { + type: "attendeePhone", + }; + } + + // Handle attendeeDefined type + if (item.type === "attendeeDefined") { + return { + type: "attendeeDefined", + }; + } + + // Fallback + return { + type: item.type, + }; +} + +/** + * Get the display name for a location type + */ +export function getLocationDisplayName(locationType: string, integration?: string): string { + // Handle integration type + if (locationType === "integration" && integration) { + return formatAppIdToDisplayName(integration); + } + + // Check default locations + const typeToDisplayName: Record = { + address: "In Person (Organizer Address)", + attendeeAddress: "In Person (Attendee Address)", + link: "Link Meeting", + phone: "Organizer Phone Number", + attendeePhone: "Attendee Phone Number", + attendeeDefined: "Custom Attendee Location", + }; + + return typeToDisplayName[locationType] || locationType; +} + +/** + * Get the icon URL for a location type + */ +export function getLocationIconUrl(locationType: string, integration?: string): string | null { + // Handle integration type + if (locationType === "integration" && integration) { + return getAppIconUrl("", integration); + } + + // Check default location icons + const typeToIcon: Record = { + address: "https://app.cal.com/map-pin-dark.svg", + attendeeAddress: "https://app.cal.com/map-pin-dark.svg", + link: "https://app.cal.com/link.svg", + phone: "https://app.cal.com/phone.svg", + attendeePhone: "https://app.cal.com/phone.svg", + attendeeDefined: "https://app.cal.com/message-pin.svg", + }; + + return typeToIcon[locationType] || null; +} + +/** + * Create a new LocationItem from a location option selection + */ +export function createLocationItemFromOption( + optionValue: string, + optionLabel: string +): LocationItem { + const id = generateLocationId(); + + // Handle integration options (format: "integrations:app-id") + if (optionValue.startsWith("integrations:")) { + const integration = optionValue.replace("integrations:", ""); + return { + id, + type: "integration", + integration, + displayName: optionLabel, + iconUrl: getAppIconUrl("", integration), + }; + } + + // Handle default location types + const defaultLocation = defaultLocations.find((loc) => loc.type === optionValue); + if (defaultLocation) { + const item: LocationItem = { + id, + type: mapDefaultLocationTypeToApiType(defaultLocation.type), + displayName: defaultLocation.label, + iconUrl: defaultLocation.iconUrl, + }; + + // Add input fields for specific types + if (defaultLocation.type === DefaultLocationType.InPerson) { + item.address = ""; + item.public = true; + } else if (defaultLocation.type === DefaultLocationType.Link) { + item.link = ""; + item.public = true; + } else if (defaultLocation.type === DefaultLocationType.UserPhone) { + item.phone = ""; + item.public = true; + } + + return item; + } + + // Fallback + return { + id, + type: optionValue as LocationItem["type"], + displayName: optionLabel, + iconUrl: null, + }; +} + +/** + * Map DefaultLocationType to API location type + */ +function mapDefaultLocationTypeToApiType(defaultType: string): LocationItem["type"] { + const typeMap: Record = { + [DefaultLocationType.AttendeeInPerson]: "attendeeAddress", + [DefaultLocationType.InPerson]: "address", + [DefaultLocationType.Phone]: "attendeePhone", + [DefaultLocationType.UserPhone]: "phone", + [DefaultLocationType.Link]: "link", + [DefaultLocationType.SomewhereElse]: "attendeeDefined", + }; + + return typeMap[defaultType] || (defaultType as LocationItem["type"]); +} + +/** + * Check if a location type requires additional input + */ +export function locationRequiresInput(locationType: LocationItem["type"]): boolean { + return ["address", "link", "phone"].includes(locationType); +} + +/** + * Get the input field type for a location + */ +export function getLocationInputType( + locationType: LocationItem["type"] +): "text" | "phone" | "url" | null { + switch (locationType) { + case "address": + return "text"; + case "link": + return "url"; + case "phone": + return "phone"; + default: + return null; + } +} + +/** + * Get the input placeholder for a location type + */ +export function getLocationInputPlaceholder(locationType: LocationItem["type"]): string { + switch (locationType) { + case "address": + return "Enter address or place"; + case "link": + return "https://meet.example.com/join/123456"; + case "phone": + return "Enter phone number"; + default: + return ""; + } +} + +/** + * Get the input label for a location type + */ +export function getLocationInputLabel(locationType: LocationItem["type"]): string { + switch (locationType) { + case "address": + return "Address"; + case "link": + return "Meeting Link"; + case "phone": + return "Phone Number"; + default: + return ""; + } +} + +/** + * Validate a location item before saving + */ +export function validateLocationItem(item: LocationItem): { valid: boolean; error?: string } { + // Integration types don't need additional validation + if (item.type === "integration") { + if (!item.integration) { + return { valid: false, error: "Integration type is required" }; + } + return { valid: true }; + } + + // Address type requires address field + if (item.type === "address") { + if (!item.address || item.address.trim() === "") { + return { valid: false, error: "Address is required" }; + } + return { valid: true }; + } + + // Link type requires link field + if (item.type === "link") { + if (!item.link || item.link.trim() === "") { + return { valid: false, error: "Meeting link is required" }; + } + // URL validation with protocol restriction + try { + const url = new URL(item.link); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { valid: false, error: "Meeting link must use http or https" }; + } + } catch { + return { valid: false, error: "Invalid meeting link URL" }; + } + return { valid: true }; + } + + // Phone type requires phone field + if (item.type === "phone") { + if (!item.phone || item.phone.trim() === "") { + return { valid: false, error: "Phone number is required" }; + } + return { valid: true }; + } + + // Other types (attendeeAddress, attendeePhone, attendeeDefined) don't need input + return { valid: true }; +} + +/** + * Build location options for dropdown from conferencing options and default locations + */ +export function buildLocationOptions( + conferencingOptions: Array<{ type: string; appId: string }> +): LocationOptionGroup[] { + // Cal Video is always available as the default conferencing app + const calVideoOption: LocationOption = { + label: "Cal Video", + iconUrl: getAppIconUrl("daily_video", "cal-video"), + value: "integrations:cal-video", + category: "conferencing", + }; + + // Group conferencing apps under "conferencing" category + const conferencingAppOptions: LocationOption[] = conferencingOptions + .filter((option) => option.appId !== "cal-video" && option.type !== "daily_video") + .map((option) => ({ + label: formatAppIdToDisplayName(option.appId), + iconUrl: getAppIconUrl(option.type, option.appId), + value: `integrations:${option.appId}`, + category: "conferencing", + })); + + // Group default locations by their category + const grouped: Record = { + conferencing: [calVideoOption, ...conferencingAppOptions], + }; + + // Add default locations by category + defaultLocations.forEach((location) => { + const option: LocationOption = { + label: location.label, + iconUrl: location.iconUrl, + value: location.type, + category: location.category, + }; + + if (!grouped[location.category]) { + grouped[location.category] = []; + } + grouped[location.category].push(option); + }); + + // Convert to array of groups with proper ordering + const categoryOrder = ["conferencing", "in person", "phone", "other"]; + const categoryLabels: Record = { + conferencing: "Conferencing", + "in person": "In Person", + phone: "Phone", + other: "Other", + }; + + return categoryOrder + .filter((category) => grouped[category] && grouped[category].length > 0) + .map((category) => ({ + category: categoryLabels[category] || category, + options: grouped[category], + })); +} + +/** + * Convert a display name back to a location value for API + * Used when selecting a location from a dropdown + * + * @param displayName - The display name shown in UI (e.g., "Google Meet", "In Person (Organizer Address)") + * @param defaultLocationsList - List of default locations to check against + * @returns Location value object for API, or null if not found + */ +export const displayNameToLocationValue = ( + displayName: string, + defaultLocationsList: Array<{ label: string; type: string }> +): { + type: string; + integration?: string; + address?: string; + link?: string; + phone?: string; + public?: boolean; +} | null => { + // First check if it's a default location + const defaultLocation = defaultLocationsList.find((loc) => loc.label === displayName); + if (defaultLocation) { + // Map internal location types to API location types + switch (defaultLocation.type) { + case "attendeeInPerson": + return { type: "attendeeAddress" }; + case "inPerson": + return { type: "address", address: "", public: true }; + case "link": + return { type: "link", link: "", public: true }; + case "phone": + return { type: "attendeePhone" }; + case "userPhone": + return { type: "phone", phone: "", public: true }; + default: + return null; + } + } + + // Check if it's a conferencing app (formatted display name) + // e.g., "Google Meet", "Zoom", etc. + const appId = displayName.toLowerCase().replace(/\s+/g, "-"); + return { type: "integration", integration: appId }; +}; diff --git a/companion/utils/network.ts b/companion/utils/network.ts new file mode 100644 index 0000000000..5ed8194357 --- /dev/null +++ b/companion/utils/network.ts @@ -0,0 +1,47 @@ +/** + * Network Utilities + * + * Helper functions for network-aware operations. + */ + +import { Alert } from "react-native"; +import NetInfo from "@react-native-community/netinfo"; + +/** + * Check if the device is currently online + * + * @returns Promise - true if online, false if offline + */ +export const isOnline = async (): Promise => { + const netState = await NetInfo.fetch(); + return netState.isConnected === true && netState.isInternetReachable !== false; +}; + +/** + * Execute a refresh function only if online. + * Shows a friendly alert if offline and preserves cached data. + * + * @param refetchFn - The refetch function to call (e.g., from React Query) + * @returns Promise + * + * @example + * ```tsx + * const { refetch } = useBookings(); + * + * const onRefresh = () => offlineAwareRefresh(refetch); + * + * + * ``` + */ +export const offlineAwareRefresh = async (refetchFn: () => Promise): Promise => { + const online = await isOnline(); + + if (!online) { + Alert.alert("You're offline", "Can't refresh right now. Showing cached data.", [ + { text: "OK" }, + ]); + return; + } + + await refetchFn(); +}; diff --git a/companion/utils/queryPersister.ts b/companion/utils/queryPersister.ts new file mode 100644 index 0000000000..bfdb47b237 --- /dev/null +++ b/companion/utils/queryPersister.ts @@ -0,0 +1,142 @@ +/** + * React Query Cache Persister + * + * Uses the shared storage adapter from utils/storage.ts for cross-platform support. + */ + +import type { Persister, PersistedClient } from "@tanstack/react-query-persist-client"; +import { CACHE_CONFIG } from "../config/cache.config"; +import { generalStorage } from "./storage"; + +// Use the shared general storage adapter for cache persistence +const storage = generalStorage; + +/** + * Create a React Query persister that works across all platforms + * + * This persister: + * - Saves the query cache to platform-appropriate storage + * - Restores cache on app launch for instant data display + * - Handles serialization/deserialization of cache data + * - Respects cache expiration (maxAge) + */ +export const createQueryPersister = (): Persister => { + const storageKey = CACHE_CONFIG.persistence.storageKey; + const maxAge = CACHE_CONFIG.persistence.maxAge; + + return { + /** + * Persist the client state to storage + */ + persistClient: async (client: PersistedClient): Promise => { + try { + const serialized = JSON.stringify(client); + await storage.setItem(storageKey, serialized); + } catch (error) { + console.warn("[QueryPersister] Failed to persist client:", error); + // Fail silently - persistence is a nice-to-have, not critical + } + }, + + /** + * Restore the client state from storage + */ + restoreClient: async (): Promise => { + try { + const serialized = await storage.getItem(storageKey); + if (!serialized) { + return undefined; + } + + const client = JSON.parse(serialized) as PersistedClient; + + // Validate timestamp exists and is a valid number + if (typeof client.timestamp !== "number" || isNaN(client.timestamp)) { + console.warn("[QueryPersister] Invalid or missing timestamp, discarding cache"); + await storage.removeItem(storageKey); + return undefined; + } + + // Check if the persisted cache has expired + const persistedAt = client.timestamp; + const now = Date.now(); + if (now - persistedAt > maxAge) { + // Cache is too old, discard it + await storage.removeItem(storageKey); + return undefined; + } + + return client; + } catch (error) { + console.warn("[QueryPersister] Failed to restore client:", error); + // If restoration fails, start fresh + return undefined; + } + }, + + /** + * Remove the persisted client state + */ + removeClient: async (): Promise => { + try { + await storage.removeItem(storageKey); + } catch (error) { + console.warn("[QueryPersister] Failed to remove client:", error); + } + }, + }; +}; + +/** + * Export the storage adapter for potential direct use + */ +export { storage }; + +/** + * Utility to clear all query cache from storage + * Useful for logout or cache reset scenarios + */ +export const clearQueryCache = async (): Promise => { + try { + await storage.removeItem(CACHE_CONFIG.persistence.storageKey); + } catch (error) { + console.warn("[QueryPersister] Failed to clear cache:", error); + } +}; + +/** + * Get cache metadata (for debugging/status display) + */ +export const getCacheMetadata = async (): Promise<{ + exists: boolean; + timestamp?: number; + age?: number; + isExpired?: boolean; +} | null> => { + try { + const serialized = await storage.getItem(CACHE_CONFIG.persistence.storageKey); + if (!serialized) { + return { exists: false }; + } + + const client = JSON.parse(serialized) as PersistedClient; + + // Validate timestamp exists and is a valid number + if (typeof client.timestamp !== "number" || isNaN(client.timestamp)) { + return { exists: true, isExpired: true }; // Treat invalid timestamp as expired + } + + const now = Date.now(); + const age = now - client.timestamp; + + return { + exists: true, + timestamp: client.timestamp, + age, + isExpired: age > CACHE_CONFIG.persistence.maxAge, + }; + } catch (error) { + console.warn("[QueryPersister] Failed to get cache metadata:", error); + return null; + } +}; diff --git a/companion/utils/storage.ts b/companion/utils/storage.ts new file mode 100644 index 0000000000..ef4f297be7 --- /dev/null +++ b/companion/utils/storage.ts @@ -0,0 +1,181 @@ +/// + +/** + * Unified Storage Adapter + * + * Cross-platform storage abstraction that works with: + * - SecureStore for React Native (iOS/Android) - for sensitive data + * - AsyncStorage for React Native (iOS/Android) - for general data + * - chrome.storage for browser extensions + * - localStorage as fallback for web + * + * This is the single source of truth for storage operations across the app. + */ + +import { Platform } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as SecureStore from "expo-secure-store"; + +/** + * Check if chrome.storage is available (browser extension context) + */ +export const isChromeStorageAvailable = (): boolean => { + return ( + Platform.OS === "web" && + typeof chrome !== "undefined" && + chrome.storage !== undefined && + chrome.storage.local !== undefined + ); +}; + +/** + * Storage interface for type safety + */ +export interface StorageAdapter { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; +} + +/** + * Secure storage for sensitive data (tokens, credentials) + * Uses SecureStore on mobile, chrome.storage on extension, localStorage on web + */ +export const secureStorage = { + get: async (key: string): Promise => { + if (isChromeStorageAvailable()) { + return new Promise((resolve, reject) => { + chrome.storage.local.get([key], (result) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve((result[key] as string) ?? null); + } + }); + }); + } + if (Platform.OS === "web") { + return localStorage.getItem(key); + } + return await SecureStore.getItemAsync(key); + }, + + set: async (key: string, value: string): Promise => { + if (isChromeStorageAvailable()) { + return new Promise((resolve, reject) => { + chrome.storage.local.set({ [key]: value }, () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + } + if (Platform.OS === "web") { + localStorage.setItem(key, value); + return; + } + await SecureStore.setItemAsync(key, value); + }, + + remove: async (key: string): Promise => { + if (isChromeStorageAvailable()) { + return new Promise((resolve, reject) => { + chrome.storage.local.remove(key, () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + } + if (Platform.OS === "web") { + localStorage.removeItem(key); + return; + } + await SecureStore.deleteItemAsync(key); + }, + + removeAll: async (keys: string[]): Promise => { + if (isChromeStorageAvailable()) { + return new Promise((resolve, reject) => { + chrome.storage.local.remove(keys, () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + } + if (Platform.OS === "web") { + keys.forEach((key) => localStorage.removeItem(key)); + return; + } + await Promise.all(keys.map((key) => SecureStore.deleteItemAsync(key))); + }, +}; + +/** + * General storage for non-sensitive data (cache, preferences) + * Uses AsyncStorage on mobile, chrome.storage on extension, localStorage on web + */ +export const generalStorage: StorageAdapter = { + getItem: async (key: string): Promise => { + if (isChromeStorageAvailable()) { + return new Promise((resolve, reject) => { + chrome.storage.local.get([key], (result) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve((result[key] as string) ?? null); + } + }); + }); + } + if (Platform.OS === "web") { + return Promise.resolve(localStorage.getItem(key)); + } + return AsyncStorage.getItem(key); + }, + + setItem: async (key: string, value: string): Promise => { + if (isChromeStorageAvailable()) { + return new Promise((resolve, reject) => { + chrome.storage.local.set({ [key]: value }, () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + } + if (Platform.OS === "web") { + localStorage.setItem(key, value); + return Promise.resolve(); + } + return AsyncStorage.setItem(key, value); + }, + + removeItem: async (key: string): Promise => { + if (isChromeStorageAvailable()) { + return new Promise((resolve, reject) => { + chrome.storage.local.remove(key, () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + } + if (Platform.OS === "web") { + localStorage.removeItem(key); + return Promise.resolve(); + } + return AsyncStorage.removeItem(key); + }, +}; diff --git a/companion/wxt.config.ts b/companion/wxt.config.ts index 705e7cc1a3..fa58ee3461 100644 --- a/companion/wxt.config.ts +++ b/companion/wxt.config.ts @@ -50,6 +50,33 @@ export default defineConfig({ "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI": JSON.stringify( process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI ), + // Cache configuration environment variables + "import.meta.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES": JSON.stringify( + process.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES + ), + "import.meta.env.EXPO_PUBLIC_CACHE_GC_TIME_MINUTES": JSON.stringify( + process.env.EXPO_PUBLIC_CACHE_GC_TIME_MINUTES + ), + "import.meta.env.EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES": JSON.stringify( + process.env.EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES + ), + "import.meta.env.EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES": JSON.stringify( + process.env.EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES + ), + "import.meta.env.EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES": JSON.stringify( + process.env.EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES + ), + "import.meta.env.EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES": JSON.stringify( + process.env.EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES + ), + // DEV ONLY: API Key for testing - only included in development builds + ...(process.env.NODE_ENV !== "production" && process.env.EXPO_PUBLIC_CAL_API_KEY + ? { + "import.meta.env.EXPO_PUBLIC_CAL_API_KEY": JSON.stringify( + process.env.EXPO_PUBLIC_CAL_API_KEY + ), + } + : {}), }, optimizeDeps: { include: ["react-native-web"],