refactor(companion): extract AvailabilityDetailScreen with AppPressable migration (#26106)

* refactor(companion): extract AvailabilityDetailScreen with AppPressable migration

Apply Phases 2, 3, 4 to availability-detail.tsx:
- Phase 2: Extract screen logic (~995 lines) to components/screens/AvailabilityDetailScreen.tsx
- Route file is now a minimal ~35 line wrapper with Stack.Screen config
- Phase 3: ScrollView+map pattern kept (7 days is a fixed small list, FlatList not needed)
- Phase 4: Migrate all TouchableOpacity to AppPressable (63 instances)

Follows the same pattern as EventTypeDetailScreen extraction.

* fix(companion): add GlassView header with Save button to AvailabilityDetailScreen (#26107)

* refactor(companion): extract BookingDetailScreen with AppPressable migration (#26108)

* refactor(companion): extract AvailabilityListScreen with AppPressable migration (#26109)

* fix(companion): add GlassView header with Save button to AvailabilityDetailScreen

* refactor(companion): extract BookingDetailScreen with AppPressable migration

* refactor(companion): extract AvailabilityListScreen with AppPressable migration

* refactor(companion): Standardized error handling with dev-only debug logs (#26110)

* address cubics comments

* address cubics comments

* addressed typecheck erros

* removed localhost from wxt.config for chrome extension

* declare FullScreenModal  FullScreenModalProps

---------

Co-authored-by: Peer Richelsen <peer@cal.com>

* add loggers for dev env

---------

Co-authored-by: Peer Richelsen <peer@cal.com>

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
This commit is contained in:
Dhairyashil Shinde
2025-12-22 22:47:55 +00:00
committed by GitHub
co-authored by Peer Richelsen
parent 9909ac53fc
commit 6650fdb9ef
31 changed files with 2606 additions and 2343 deletions
+12 -2
View File
@@ -55,7 +55,12 @@ const sendFile = (res: ServerResponse, filePath: string) => {
const stream = createReadStream(filePath);
stream.on("error", (error) => {
console.error("Failed to read file:", error);
console.error("Failed to read file");
if (process.env.NODE_ENV !== "production") {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[companion/api] Failed to read file", { message, stack });
}
res.statusCode = 500;
res.end("Internal Server Error");
});
@@ -76,7 +81,12 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
const filePath = await resolveFilePath(safePath);
sendFile(res, filePath);
} catch (error) {
console.error("Server render error:", error);
console.error("Server render error");
if (process.env.NODE_ENV !== "production") {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[companion/api] Server render error", { message, stack });
}
res.statusCode = 500;
res.end("Internal Server Error");
}
+12 -600
View File
@@ -1,306 +1,22 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import React, { useState, useMemo, Activity } from "react";
import {
View,
Text,
FlatList,
RefreshControl,
TouchableOpacity,
ActionSheetIOS,
Alert,
Platform,
TextInput,
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 { shadows } from "../../../utils/shadows";
import {
useSchedules,
useCreateSchedule,
useDeleteSchedule,
useDuplicateSchedule,
useSetScheduleAsDefault,
} from "../../../hooks";
import { AvailabilityListScreen } from "../../../components/screens/AvailabilityListScreen";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { AvailabilityListItem } from "../../../components/availability-list-item/AvailabilityListItem";
import { Stack } from "expo-router";
import React, { useState } from "react";
import { Platform } from "react-native";
export default function Availability() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [newScheduleName, setNewScheduleName] = useState("");
const [showActionsModal, setShowActionsModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
// Use React Query hooks
const {
data: schedules = [],
isLoading: loading,
isFetching,
error: queryError,
refetch,
} = useSchedules();
// Show refresh indicator when fetching
const refreshing = isFetching && !loading;
const { mutate: createScheduleMutation, isPending: creating } = useCreateSchedule();
const { mutate: deleteScheduleMutation, isPending: deleting } = useDeleteSchedule();
const { mutate: duplicateScheduleMutation } = useDuplicateSchedule();
const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault();
// 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]);
// 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.
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleScheduleLongPress = (schedule: Schedule) => {
if (Platform.OS !== "ios") {
// Fallback for non-iOS platforms (Android Alert supports max 3 buttons)
const options = [];
if (!schedule.isDefault) {
options.push({ text: "⭐ Set as default", onPress: () => handleSetAsDefault(schedule) });
}
options.push(
{ text: "📋 Duplicate", onPress: () => handleDuplicate(schedule) },
{ text: "🗑️ Delete", style: "destructive", onPress: () => handleDelete(schedule) }
);
// Android Alert automatically adds cancel, so we don't need to include it explicitly
Alert.alert(schedule.name, "", options);
return;
}
const options = ["Cancel"];
if (!schedule.isDefault) {
options.push("⭐ Set as default");
}
options.push("📋 Duplicate", "🗑️ Delete");
const destructiveButtonIndex = options.length - 1; // Delete button
const cancelButtonIndex = 0;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex,
cancelButtonIndex,
title: schedule.name,
},
(buttonIndex) => {
if (buttonIndex === cancelButtonIndex) {
return;
}
if (!schedule.isDefault) {
// Options: ["Cancel", "⭐ Set as default", "📋 Duplicate", "🗑️ Delete"]
if (buttonIndex === 1) {
handleSetAsDefault(schedule);
} else if (buttonIndex === 2) {
handleDuplicate(schedule);
} else if (buttonIndex === 3) {
handleDelete(schedule);
}
} else {
// Options: ["Cancel", "📋 Duplicate", "🗑️ Delete"]
if (buttonIndex === 1) {
handleDuplicate(schedule);
} else if (buttonIndex === 2) {
handleDelete(schedule);
}
}
}
);
};
const handleSetAsDefault = (schedule: Schedule) => {
setAsDefaultMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
},
});
};
const handleDuplicate = (schedule: Schedule) => {
duplicateScheduleMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to duplicate schedule. Please try again.");
},
});
};
const handleDelete = (schedule: Schedule) => {
if (Platform.OS === "web") {
// Use custom modal for web
setShowDeleteModal(true);
} else {
// Use native Alert for iOS/Android
Alert.alert("Delete Schedule", `Are you sure you want to delete "${schedule.name}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteScheduleMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
},
});
},
},
]);
}
};
const confirmDelete = () => {
if (!selectedSchedule) return;
deleteScheduleMutation(selectedSchedule.id, {
onSuccess: () => {
setShowDeleteModal(false);
setSelectedSchedule(null);
},
onError: () => {
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
},
});
};
const handleSchedulePress = (schedule: Schedule) => {
router.push({
pathname: "/availability-detail",
params: { id: schedule.id.toString() },
});
};
const handleCreateNew = () => {
setNewScheduleName("");
setShowCreateModal(true);
};
const handleCreateSchedule = async () => {
if (!newScheduleName.trim()) {
Alert.alert("Error", "Please enter a schedule name");
return;
}
// 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");
}
// Create schedule with Monday-Friday 9 AM - 5 PM default
createScheduleMutation(
{
name: newScheduleName.trim(),
timeZone: userTimezone,
isDefault: false,
availability: [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
],
},
{
onSuccess: (newSchedule) => {
setShowCreateModal(false);
setNewScheduleName("");
// Navigate to edit the newly created schedule
router.push({
pathname: "/availability-detail",
params: {
id: newSchedule.id.toString(),
},
});
},
onError: (error) => {
console.error("Failed to create schedule:", error);
showErrorAlert("Error", "Failed to create schedule. Please try again.");
},
}
);
};
if (loading) {
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<LoadingSpinner size="large" />
</View>
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-[#333]">
Unable to load availability
</Text>
<Text className="mb-6 text-center text-base text-[#666]">{error}</Text>
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={() => refetch()}>
<Text className="text-base font-semibold text-white">Retry</Text>
</TouchableOpacity>
</View>
</View>
);
}
// Determine what content to show
const showEmptyState = schedules.length === 0 && !loading;
const showSearchEmptyState =
filteredSchedules.length === 0 && searchQuery.trim() !== "" && !showEmptyState;
const showList = !showEmptyState && !showSearchEmptyState;
return (
<>
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : "light"} // Only looks cool on iOS 18 and below
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
hidden={Platform.OS === "android"}
>
<Stack.Header.Title large>Availability</Stack.Header.Title>
@@ -311,322 +27,18 @@ export default function Availability() {
</Stack.Header.Right>
<Stack.Header.SearchBar
placeholder="Search schedules"
onChangeText={(e) => handleSearch(e.nativeEvent.text)}
onChangeText={(e) => setSearchQuery(e.nativeEvent.text)}
obscureBackground={false}
barTintColor="#fff"
/>
</Stack.Header>
<Activity mode={Platform.OS !== "ios" ? "visible" : "hidden"}>
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
</Activity>
{/* Empty state - no schedules */}
<Activity mode={showEmptyState ? "visible" : "hidden"}>
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
paddingBottom: 90,
}}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
contentInsetAdjustmentBehavior="automatic"
>
<EmptyScreen
icon="time-outline"
headline="Create an availability schedule"
description="Creating availability schedules allows you to manage availability across event types. They can be applied to one or more event types."
buttonText="New"
onButtonPress={handleCreateNew}
/>
</ScrollView>
</Activity>
{/* Search bar and content for non-empty states */}
<Activity mode={!showEmptyState ? "visible" : "hidden"}>
{/* Search empty state */}
<Activity mode={showSearchEmptyState ? "visible" : "hidden"}>
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
paddingBottom: 90,
}}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</ScrollView>
</Activity>
{/* Schedules list */}
<Activity mode={showList ? "visible" : "hidden"}>
<FlatList
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white"
contentContainerStyle={{
paddingBottom: 90,
paddingHorizontal: 8,
paddingVertical: 4,
}}
data={filteredSchedules}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
<AvailabilityListItem
item={item}
index={index}
handleSchedulePress={handleSchedulePress}
handleScheduleLongPress={handleScheduleLongPress}
setSelectedSchedule={setSelectedSchedule}
setShowActionsModal={setShowActionsModal}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
onSetAsDefault={handleSetAsDefault}
/>
)}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
/>
</Activity>
</Activity>
{/* Create Schedule Modal */}
<FullScreenModal
visible={showCreateModal}
animationType="fade"
onRequestClose={() => setShowCreateModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowCreateModal(false)}
>
<TouchableOpacity
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={shadows.xl()}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<Text className="text-2xl font-semibold text-[#111827]">Add a new schedule</Text>
</View>
{/* Content */}
<View className="px-8 pb-6">
<View className="mb-1">
<Text className="mb-2 text-sm font-medium text-[#374151]">Name</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
placeholder="Working Hours"
placeholderTextColor="#9CA3AF"
value={newScheduleName}
onChangeText={setNewScheduleName}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateSchedule}
/>
</View>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<TouchableOpacity
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
setShowCreateModal(false);
setNewScheduleName("");
}}
disabled={creating}
>
<Text className="text-base font-medium text-[#374151]">Close</Text>
</TouchableOpacity>
<TouchableOpacity
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${creating ? "opacity-60" : ""}`}
onPress={handleCreateSchedule}
disabled={creating}
>
<Text className="text-base font-medium text-white">Continue</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Schedule Actions Modal */}
<FullScreenModal
visible={showActionsModal}
animationType="fade"
onRequestClose={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="border-b border-gray-200 p-6">
<Text className="text-center text-xl font-semibold text-gray-900">
Schedule Actions
</Text>
</View>
{/* Actions List */}
<View className="p-2">
{/* Set as Default - only show if not already default */}
<Activity
mode={selectedSchedule && !selectedSchedule.isDefault ? "visible" : "hidden"}
>
<>
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
handleSetAsDefault(selectedSchedule);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="star-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Set as Default</Text>
</TouchableOpacity>
<View className="mx-4 my-2 h-px bg-gray-200" />
</>
</Activity>
{/* Duplicate */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
setTimeout(() => {
handleDuplicate(selectedSchedule);
}, 100);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="copy-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Duplicate</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Delete */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
// Small delay to ensure modal closes before alert shows
setTimeout(() => {
handleDelete(selectedSchedule);
}, 100);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="trash-outline" size={20} color="#800000" />
<Text className="ml-3 text-base text-[#800000]">Delete</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<TouchableOpacity
className="w-full rounded-lg bg-gray-100 p-3"
onPress={() => setShowActionsModal(false)}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Delete Confirmation Modal */}
<FullScreenModal
visible={showDeleteModal}
animationType="fade"
onRequestClose={() => !deleting && setShowDeleteModal(false)}
>
<View className="flex-1 items-center justify-center bg-black/50 p-4">
<View className="w-full max-w-sm rounded-2xl bg-white p-6">
{/* Icon */}
<View className="mb-4 items-center">
<View className="h-12 w-12 items-center justify-center rounded-full bg-red-100">
<Ionicons name="trash-outline" size={24} color="#800000" />
</View>
</View>
{/* Title */}
<Text className="mb-2 text-center text-xl font-semibold text-gray-900">
Delete Schedule
</Text>
{/* Description */}
<Text className="mb-6 text-center text-base text-gray-600">
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be
undone.
</Text>
{/* Buttons */}
<View className="flex-row gap-3">
<TouchableOpacity
className="flex-1 rounded-lg bg-gray-100 px-4 py-3"
onPress={() => setShowDeleteModal(false)}
disabled={deleting}
>
<Text className="text-center text-base font-semibold text-gray-700">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 rounded-lg bg-gray-900 px-4 py-3 ${deleting ? "opacity-50" : ""}`}
onPress={confirmDelete}
disabled={deleting}
>
<Text className="text-center text-base font-semibold text-white">Delete</Text>
</TouchableOpacity>
</View>
</View>
</View>
</FullScreenModal>
<AvailabilityListScreen
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
showCreateModal={showCreateModal}
onShowCreateModalChange={setShowCreateModal}
/>
</>
);
}
+19 -4
View File
@@ -243,7 +243,12 @@ export default function EventTypes() {
}
},
onError: (error) => {
console.error("Failed to delete event type:", error);
const message = error instanceof Error ? error.message : String(error);
console.error("Failed to delete event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[EventTypes] deleteEventType failed", { message, stack });
}
if (Platform.OS === "web") {
showToastMessage("Failed to delete event type");
} else {
@@ -283,7 +288,12 @@ export default function EventTypes() {
});
},
onError: (error) => {
console.error("Failed to duplicate event type:", error);
const message = error instanceof Error ? error.message : String(error);
console.error("Failed to duplicate event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[EventTypes] duplicateEventType failed", { message, stack });
}
if (Platform.OS === "web") {
showToastMessage("Failed to duplicate event type");
} else {
@@ -305,7 +315,7 @@ export default function EventTypes() {
await openInAppBrowser(link, "event type preview");
}
} catch (error) {
console.error("Failed to open preview:", error);
console.error("Failed to open preview");
if (Platform.OS === "web") {
showToastMessage("Failed to open preview");
} else {
@@ -373,7 +383,12 @@ export default function EventTypes() {
});
},
onError: (error) => {
console.error("Failed to create event type:", error);
const message = error instanceof Error ? error.message : String(error);
console.error("Failed to create event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[EventTypes] createEventType failed", { message, stack });
}
showErrorAlert("Error", "Failed to create event type. Please try again.");
},
}
+6 -1
View File
@@ -25,7 +25,12 @@ export default function More() {
try {
await logout();
} catch (error) {
console.error("Logout error:", error);
const message = error instanceof Error ? error.message : String(error);
console.error("Logout error", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[More] logout failed", { message, stack });
}
showErrorAlert("Error", "Failed to sign out. Please try again.");
}
};
File diff suppressed because it is too large Load Diff
+7 -672
View File
@@ -1,683 +1,18 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
import React, { useState, useEffect } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
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 { shadows } from "../utils/shadows";
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 => {
if (!dateString) return "";
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
} catch (error) {
return "";
}
};
// Format time: "9:40pm - 10:00pm"
const formatTime12Hour = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? "pm" : "am";
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const minStr = minutes.toString().padStart(2, "0");
return `${hour12}:${minStr}${period}`;
} catch (error) {
return "";
}
};
// Get timezone from date string
const getTimezone = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return timeZone;
} catch (error) {
return "";
}
};
// Get initials from a name (e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS")
const getInitials = (name: string): string => {
if (!name) return "";
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return "";
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
// Get first letter of first name and first letter of last name
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
};
// Get location provider info
const getLocationProvider = (location: string | undefined, metadata?: Record<string, any>) => {
// Check metadata for videoCallUrl first
const videoCallUrl = metadata?.videoCallUrl;
const locationToCheck = videoCallUrl || location;
if (!locationToCheck) return null;
// Check if it's a video call URL
if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) {
// Try to detect provider from URL
if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) {
const iconUrl = getAppIconUrl("daily_video", "cal-video");
return {
label: "Cal Video",
iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg",
url: locationToCheck,
};
}
// Check for other video providers by URL pattern
const videoProviders = [
{ pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" },
{
pattern: /meet\.google\.com/,
label: "Google Meet",
type: "google_video",
appId: "google-meet",
},
{
pattern: /teams\.microsoft\.com/,
label: "Microsoft Teams",
type: "office365_video",
appId: "msteams",
},
];
for (const provider of videoProviders) {
if (provider.pattern.test(locationToCheck)) {
const iconUrl = getAppIconUrl(provider.type, provider.appId);
return {
label: provider.label,
iconUrl: iconUrl,
url: locationToCheck,
};
}
}
// Generic link meeting
const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg";
return {
label: "Link Meeting",
iconUrl: linkIconUrl,
url: locationToCheck,
};
}
// Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video")
if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) {
const appId = locationToCheck.replace("integrations:", "");
const iconUrl = getAppIconUrl("", appId);
if (iconUrl) {
return {
label: formatAppIdToDisplayName(appId),
iconUrl: iconUrl,
url: null,
};
}
}
// Check if it's a default location type
const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck);
if (defaultLocation) {
return {
label: defaultLocation.label,
iconUrl: defaultLocation.iconUrl,
url: null,
};
}
// Fallback: return as plain text location
return {
label: locationToCheck,
iconUrl: null,
url: null,
};
};
import { BookingDetailScreen } from "../components/screens/BookingDetailScreen";
import { useLocalSearchParams, Stack } from "expo-router";
import React from "react";
export default function BookingDetail() {
const router = useRouter();
const { uid } = useLocalSearchParams<{ uid: string }>();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(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);
useEffect(() => {
if (uid) {
fetchBooking();
}
}, [uid]);
const fetchBooking = async () => {
try {
setLoading(true);
setError(null);
const bookingData = await CalComAPIService.getBookingByUid(uid);
console.log("Booking data received:", JSON.stringify(bookingData, null, 2));
console.log("User:", bookingData.user);
console.log("Hosts:", bookingData.hosts);
console.log("Attendees:", bookingData.attendees);
console.log("Recurring fields:", {
recurringEventId: bookingData.recurringEventId,
recurringBookingUid: (bookingData as any).recurringBookingUid,
metadata: (bookingData as any).metadata,
});
setBooking(bookingData);
} catch (err) {
console.error("Error fetching booking:", err);
setError("Failed to load booking. Please try again.");
if (__DEV__) {
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} else {
router.back();
}
} finally {
setLoading(false);
}
};
const handleJoinMeeting = () => {
if (!booking?.location) return;
const provider = getLocationProvider(booking.location);
if (provider?.url) {
openInAppBrowser(provider.url, "meeting link");
}
};
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 {
await CalComAPIService.rescheduleBooking(booking.uid, {
start: startUtc,
reschedulingReason: rescheduleReason.trim() || undefined,
});
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);
showErrorAlert("Error", "Failed to reschedule booking. Please try again.");
} finally {
setRescheduling(false);
}
};
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-gray-500">Loading booking...</Text>
</View>
);
if (!uid) {
return null;
}
if (error || !booking) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa] p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{error || "Booking not found"}
</Text>
<TouchableOpacity
className="mt-6 rounded-lg bg-black px-6 py-3"
onPress={() => router.back()}
>
<Text className="text-base font-semibold text-white">Go Back</Text>
</TouchableOpacity>
</View>
);
}
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const dateFormatted = formatDateFull(startTime);
const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
const timezone = getTimezone(startTime);
const locationProvider = getLocationProvider(booking.location, booking.responses);
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerTitle: booking?.title || "Booking Details",
headerLeft: () => (
<TouchableOpacity
onPress={() => router.back()}
className="items-center justify-center rounded-full"
style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }}
>
<Ionicons name="chevron-back" size={24} color="#000" />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
onPress={() => setShowActionsModal(true)}
className="items-center justify-center rounded-full"
style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }}
>
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</TouchableOpacity>
),
}}
/>
<View className="flex-1 bg-[#f8f9fa]">
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, paddingBottom: 100 }}>
{/* Title */}
<View className="mb-3">
<Text className="mb-2 text-2xl font-semibold text-[#333]">{booking.title}</Text>
<Text className="text-base text-[#666]">
{dateFormatted} {timeFormatted} ({timezone})
</Text>
</View>
{/* Who Section */}
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Who</Text>
{/* Show host from user field or hosts array */}
{booking.user || (booking.hosts && booking.hosts.length > 0) ? (
<View className="mb-4">
{booking.user ? (
<View className="flex-row items-start">
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(booking.user.name)}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{booking.user.name}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
<Text className="text-sm text-[#666]">{booking.user.email}</Text>
</View>
</View>
) : booking.hosts && booking.hosts.length > 0 ? (
booking.hosts.map((host, hostIndex) => (
<View
key={hostIndex}
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}
>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(host.name || "Host")}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{host.name || "Host"}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
{host.email && <Text className="text-sm text-[#666]">{host.email}</Text>}
</View>
</View>
))
) : null}
</View>
) : null}
{booking.attendees && booking.attendees.length > 0 ? (
<View>
{booking.attendees.map((attendee, index) => (
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(attendee.name)}
</Text>
</View>
<View className="flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
{attendee.name}
</Text>
<Text className="text-sm text-[#666]">{attendee.email}</Text>
</View>
</View>
))}
</View>
) : null}
</View>
{/* Where Section */}
{locationProvider ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
{locationProvider.url ? (
<TouchableOpacity
onPress={handleJoinMeeting}
className="flex-row flex-wrap items-center"
>
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
{locationProvider.url}
</Text>
</TouchableOpacity>
) : (
<View className="flex-row items-center">
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
</View>
)}
</View>
) : null}
{/* Recurring Event Section */}
{booking.recurringEventId || booking.recurringBookingUid ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Recurring Event</Text>
<Text className="text-base text-[#666]">Every 2 weeks for 6 occurrences</Text>
</View>
) : null}
{/* Description Section */}
{booking.description ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Description</Text>
<Text className="text-base leading-6 text-[#666]">{booking.description}</Text>
</View>
) : null}
{/* Join Meeting Button */}
{locationProvider?.url ? (
<TouchableOpacity
onPress={handleJoinMeeting}
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
>
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base font-semibold text-white">
Join {locationProvider.label}
</Text>
</TouchableOpacity>
) : null}
</ScrollView>
</View>
{/* Booking Actions Modal */}
<BookingActionsModal
visible={showActionsModal}
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 */}
<FullScreenModal
visible={showRescheduleModal}
animationType="fade"
onRequestClose={() => setShowRescheduleModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowRescheduleModal(false)}
>
<TouchableOpacity
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={shadows.xl()}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<View className="flex-row items-start space-x-3">
<View className="h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#F3F4F6]">
<Ionicons name="calendar-outline" size={24} color="#111827" />
</View>
<View className="flex-1 pt-1">
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
Reschedule Booking
</Text>
<Text className="text-sm text-[#6B7280]">
Select a new date and time for this booking.
</Text>
</View>
</View>
</View>
{/* Content */}
<ScrollView className="max-h-[400px] px-8 pb-6">
{/* Date Input */}
<View className="mb-4">
<Text className="mb-2 text-sm font-bold text-[#111827]">
New Date
<Text className="font-normal text-[#6B7280]"> (YYYY-MM-DD)</Text>
</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="2024-12-25"
placeholderTextColor="#9CA3AF"
value={rescheduleDate}
onChangeText={setRescheduleDate}
editable={!rescheduling}
keyboardType="default"
/>
</View>
{/* Time Input */}
<View className="mb-4">
<Text className="mb-2 text-sm font-bold text-[#111827]">
New Time
<Text className="font-normal text-[#6B7280]"> (HH:MM, 24-hour)</Text>
</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="14:30"
placeholderTextColor="#9CA3AF"
value={rescheduleTime}
onChangeText={setRescheduleTime}
editable={!rescheduling}
keyboardType="default"
/>
</View>
{/* Reason Input */}
<View className="mb-2">
<Text className="mb-2 text-sm font-bold text-[#111827]">
Reason
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
</Text>
<TextInput
className="min-h-[80px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="Enter reason for rescheduling..."
placeholderTextColor="#9CA3AF"
value={rescheduleReason}
onChangeText={setRescheduleReason}
multiline
numberOfLines={3}
textAlignVertical="top"
editable={!rescheduling}
/>
</View>
</ScrollView>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<TouchableOpacity
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
setShowRescheduleModal(false);
setRescheduleDate("");
setRescheduleTime("");
setRescheduleReason("");
}}
disabled={rescheduling}
>
<Text className="text-base font-medium text-[#374151]">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${rescheduling ? "opacity-60" : ""}`}
onPress={handleReschedule}
disabled={rescheduling}
>
{rescheduling ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-base font-medium text-white">Reschedule</Text>
)}
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
<Stack.Screen options={{ headerShown: false }} />
<BookingDetailScreen uid={uid} />
</>
);
}
+1 -1
View File
@@ -1678,7 +1678,7 @@ export default function EventTypeDetail() {
schedulesLoading={schedulesLoading}
scheduleDetailsLoading={scheduleDetailsLoading}
selectedScheduleDetails={selectedScheduleDetails}
getDaySchedule={getDaySchedule}
getDaySchedules={getDaySchedule}
formatTime={formatTime}
selectedTimezone={selectedTimezone}
/>
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import { TouchableOpacity } from "react-native";
import type { TouchableOpacityProps } from "react-native";
type AppPressableProps = TouchableOpacityProps & {
className?: string;
};
export const AppPressable = React.forwardRef<
React.ElementRef<typeof TouchableOpacity>,
AppPressableProps
>(({ className, ...props }, ref) => {
return <TouchableOpacity ref={ref} className={className} {...props} />;
});
AppPressable.displayName = "AppPressable";
+3 -5
View File
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import { setAuthToken, setWebSession } from "../services/calcom";
import { CalComAPIService } from "../services/calcom";
export function AuthTokenProvider({ children }: { children: React.ReactNode }) {
const { accessToken, isAuthenticated, isWebSession } = useAuth();
@@ -8,12 +8,10 @@ export function AuthTokenProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (isAuthenticated) {
if (accessToken) {
setAuthToken(accessToken);
CalComAPIService.setAccessToken(accessToken);
}
setWebSession(isWebSession);
} else {
setAuthToken(null);
setWebSession(false);
CalComAPIService.clearAuth();
}
}, [isAuthenticated, accessToken, isWebSession]);
+10
View File
@@ -0,0 +1,10 @@
import type React from "react";
import type { ModalProps } from "react-native";
export interface FullScreenModalProps extends ModalProps {
visible: boolean;
onRequestClose: () => void;
children: React.ReactNode;
}
export declare function FullScreenModal(props: FullScreenModalProps): JSX.Element;
+12 -2
View File
@@ -34,7 +34,12 @@ export function Header() {
const profile = await CalComAPIService.getUserProfile();
setUserProfile(profile);
} catch (error) {
console.error("Failed to fetch user profile:", error);
console.error("Failed to fetch user profile");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[Header] fetchUserProfile failed", { message, stack });
}
} finally {
setLoading(false);
}
@@ -55,7 +60,12 @@ export function Header() {
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);
console.error("Failed to copy public page link");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[Header] copyPublicPageLink failed", { message, stack });
}
Alert.alert("Error", "Failed to copy link. Please try again.");
}
};
+1 -1
View File
@@ -14,7 +14,7 @@ export function LoginScreen() {
try {
await loginWithOAuth();
} catch (error) {
console.error("OAuth login error:", error);
console.error("OAuth login error");
showErrorAlert(
"Login Failed",
error instanceof Error
+6 -1
View File
@@ -35,7 +35,12 @@ export function LogoutButton({ className = "" }: LogoutButtonProps) {
try {
await logout();
} catch (error) {
console.error("Logout error:", error);
const message = error instanceof Error ? error.message : String(error);
console.error("Logout error", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[LogoutButton] logout failed", { message, stack });
}
if (Platform.OS === "web") {
window.alert("Failed to sign out. Please try again.");
} else {
@@ -16,7 +16,7 @@ interface AvailabilityTabProps {
schedulesLoading: boolean;
scheduleDetailsLoading: boolean;
selectedScheduleDetails: any;
getDaySchedule: () => DaySchedule[];
getDaySchedules: () => DaySchedule[];
formatTime: (time: string) => string;
selectedTimezone: string;
}
@@ -60,7 +60,7 @@ export function AvailabilityTab(props: AvailabilityTabProps) {
<Text className="text-sm italic text-[#8E8E93]">Loading schedule details...</Text>
</View>
) : props.selectedScheduleDetails ? (
props.getDaySchedule().map((dayInfo, index) => (
props.getDaySchedules().map((dayInfo, index) => (
<View key={index} className="flex-row items-center justify-between py-4">
<Text
className={`ml-2 flex-1 text-[15px] font-medium text-[#333] ${
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,631 @@
import { AppPressable } from "../../components/AppPressable";
import { EmptyScreen } from "../../components/EmptyScreen";
import { FullScreenModal } from "../../components/FullScreenModal";
import { Header } from "../../components/Header";
import { LoadingSpinner } from "../../components/LoadingSpinner";
import { AvailabilityListItem } from "../../components/availability-list-item/AvailabilityListItem";
import {
useSchedules,
useCreateSchedule,
useDeleteSchedule,
useDuplicateSchedule,
useSetScheduleAsDefault,
} from "../../hooks";
import { CalComAPIService, Schedule } from "../../services/calcom";
import { showErrorAlert } from "../../utils/alerts";
import { offlineAwareRefresh } from "../../utils/network";
import { shadows } from "../../utils/shadows";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { useState, useMemo, Activity } from "react";
import {
View,
Text,
FlatList,
RefreshControl,
ActionSheetIOS,
Alert,
Platform,
TextInput,
ScrollView,
} from "react-native";
export interface AvailabilityListScreenProps {
searchQuery: string;
onSearchChange: (query: string) => void;
showCreateModal: boolean;
onShowCreateModalChange: (show: boolean) => void;
}
export function AvailabilityListScreen({
searchQuery,
onSearchChange,
showCreateModal,
onShowCreateModalChange,
}: AvailabilityListScreenProps) {
const router = useRouter();
const [newScheduleName, setNewScheduleName] = useState("");
const [showActionsModal, setShowActionsModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
// Use React Query hooks
const {
data: schedules = [],
isLoading: loading,
isFetching,
error: queryError,
refetch,
} = useSchedules();
// Show refresh indicator when fetching
const refreshing = isFetching && !loading;
const { mutate: createScheduleMutation, isPending: creating } = useCreateSchedule();
const { mutate: deleteScheduleMutation, isPending: deleting } = useDeleteSchedule();
const { mutate: duplicateScheduleMutation } = useDuplicateSchedule();
const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault();
// 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]);
// 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.
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
const handleSearch = (query: string) => {
onSearchChange(query);
};
const handleScheduleLongPress = (schedule: Schedule) => {
if (Platform.OS !== "ios") {
// Fallback for non-iOS platforms (Android Alert supports max 3 buttons)
const options = [];
if (!schedule.isDefault) {
options.push({ text: "Set as default", onPress: () => handleSetAsDefault(schedule) });
}
options.push(
{ text: "Duplicate", onPress: () => handleDuplicate(schedule) },
{ text: "Delete", style: "destructive" as const, onPress: () => handleDelete(schedule) }
);
// Android Alert automatically adds cancel, so we don't need to include it explicitly
Alert.alert(schedule.name, "", options);
return;
}
const options = ["Cancel"];
if (!schedule.isDefault) {
options.push("Set as default");
}
options.push("Duplicate", "Delete");
const destructiveButtonIndex = options.length - 1; // Delete button
const cancelButtonIndex = 0;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex,
cancelButtonIndex,
title: schedule.name,
},
(buttonIndex) => {
if (buttonIndex === cancelButtonIndex) {
return;
}
if (!schedule.isDefault) {
// Options: ["Cancel", "Set as default", "Duplicate", "Delete"]
if (buttonIndex === 1) {
handleSetAsDefault(schedule);
} else if (buttonIndex === 2) {
handleDuplicate(schedule);
} else if (buttonIndex === 3) {
handleDelete(schedule);
}
} else {
// Options: ["Cancel", "Duplicate", "Delete"]
if (buttonIndex === 1) {
handleDuplicate(schedule);
} else if (buttonIndex === 2) {
handleDelete(schedule);
}
}
}
);
};
const handleSetAsDefault = (schedule: Schedule) => {
setAsDefaultMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
},
});
};
const handleDuplicate = (schedule: Schedule) => {
duplicateScheduleMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to duplicate schedule. Please try again.");
},
});
};
const handleDelete = (schedule: Schedule) => {
if (Platform.OS === "web") {
// Use custom modal for web
setShowDeleteModal(true);
} else {
// Use native Alert for iOS/Android
Alert.alert("Delete Schedule", `Are you sure you want to delete "${schedule.name}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteScheduleMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
},
});
},
},
]);
}
};
const confirmDelete = () => {
if (!selectedSchedule) return;
deleteScheduleMutation(selectedSchedule.id, {
onSuccess: () => {
setShowDeleteModal(false);
setSelectedSchedule(null);
},
onError: () => {
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
},
});
};
const handleSchedulePress = (schedule: Schedule) => {
router.push({
pathname: "/availability-detail",
params: { id: schedule.id.toString() },
});
};
const handleCreateNew = () => {
setNewScheduleName("");
onShowCreateModalChange(true);
};
const handleCreateSchedule = async () => {
if (!newScheduleName.trim()) {
Alert.alert("Error", "Please enter a schedule name");
return;
}
// 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");
}
// Create schedule with Monday-Friday 9 AM - 5 PM default
createScheduleMutation(
{
name: newScheduleName.trim(),
timeZone: userTimezone,
isDefault: false,
availability: [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
],
},
{
onSuccess: (newSchedule) => {
onShowCreateModalChange(false);
setNewScheduleName("");
// Navigate to edit the newly created schedule
router.push({
pathname: "/availability-detail",
params: {
id: newSchedule.id.toString(),
},
});
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error);
console.error("Failed to create schedule", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[AvailabilityListScreen] createSchedule failed", { message, stack });
}
showErrorAlert("Error", "Failed to create schedule. Please try again.");
},
}
);
};
if (loading) {
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<LoadingSpinner size="large" />
</View>
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-[#333]">
Unable to load availability
</Text>
<Text className="mb-6 text-center text-base text-[#666]">{error}</Text>
<AppPressable className="rounded-lg bg-black px-6 py-3" onPress={() => refetch()}>
<Text className="text-base font-semibold text-white">Retry</Text>
</AppPressable>
</View>
</View>
);
}
// Determine what content to show
const showEmptyState = schedules.length === 0 && !loading;
const showSearchEmptyState =
filteredSchedules.length === 0 && searchQuery.trim() !== "" && !showEmptyState;
const showList = !showEmptyState && !showSearchEmptyState;
return (
<>
{/* Non-iOS header with search */}
<Activity mode={Platform.OS !== "ios" ? "visible" : "hidden"}>
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<AppPressable
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</AppPressable>
</View>
</Activity>
{/* Empty state - no schedules */}
<Activity mode={showEmptyState ? "visible" : "hidden"}>
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
paddingBottom: 90,
}}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
contentInsetAdjustmentBehavior="automatic"
>
<EmptyScreen
icon="time-outline"
headline="Create an availability schedule"
description="Creating availability schedules allows you to manage availability across event types. They can be applied to one or more event types."
buttonText="New"
onButtonPress={handleCreateNew}
/>
</ScrollView>
</Activity>
{/* Search bar and content for non-empty states */}
<Activity mode={!showEmptyState ? "visible" : "hidden"}>
{/* Search empty state */}
<Activity mode={showSearchEmptyState ? "visible" : "hidden"}>
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
paddingBottom: 90,
}}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</ScrollView>
</Activity>
{/* Schedules list */}
<Activity mode={showList ? "visible" : "hidden"}>
<FlatList
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white"
contentContainerStyle={{
paddingBottom: 90,
paddingHorizontal: 8,
paddingVertical: 4,
}}
data={filteredSchedules}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
<AvailabilityListItem
item={item}
index={index}
handleSchedulePress={handleSchedulePress}
handleScheduleLongPress={handleScheduleLongPress}
setSelectedSchedule={setSelectedSchedule}
setShowActionsModal={setShowActionsModal}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
onSetAsDefault={handleSetAsDefault}
/>
)}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
/>
</Activity>
</Activity>
{/* Create Schedule Modal */}
<FullScreenModal
visible={showCreateModal}
animationType="fade"
onRequestClose={() => onShowCreateModalChange(false)}
>
<AppPressable
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => onShowCreateModalChange(false)}
>
<AppPressable
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={shadows.xl()}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<Text className="text-2xl font-semibold text-[#111827]">Add a new schedule</Text>
</View>
{/* Content */}
<View className="px-8 pb-6">
<View className="mb-1">
<Text className="mb-2 text-sm font-medium text-[#374151]">Name</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
placeholder="Working Hours"
placeholderTextColor="#9CA3AF"
value={newScheduleName}
onChangeText={setNewScheduleName}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateSchedule}
/>
</View>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<AppPressable
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
onShowCreateModalChange(false);
setNewScheduleName("");
}}
disabled={creating}
>
<Text className="text-base font-medium text-[#374151]">Close</Text>
</AppPressable>
<AppPressable
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${
creating ? "opacity-60" : ""
}`}
onPress={handleCreateSchedule}
disabled={creating}
>
<Text className="text-base font-medium text-white">Continue</Text>
</AppPressable>
</View>
</View>
</AppPressable>
</AppPressable>
</FullScreenModal>
{/* Schedule Actions Modal */}
<FullScreenModal
visible={showActionsModal}
animationType="fade"
onRequestClose={() => setShowActionsModal(false)}
>
<AppPressable
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowActionsModal(false)}
>
<AppPressable
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="border-b border-gray-200 p-6">
<Text className="text-center text-xl font-semibold text-gray-900">
Schedule Actions
</Text>
</View>
{/* Actions List */}
<View className="p-2">
{/* Set as Default - only show if not already default */}
<Activity
mode={selectedSchedule && !selectedSchedule.isDefault ? "visible" : "hidden"}
>
<>
<AppPressable
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
handleSetAsDefault(selectedSchedule);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="star-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Set as Default</Text>
</AppPressable>
<View className="mx-4 my-2 h-px bg-gray-200" />
</>
</Activity>
{/* Duplicate */}
<AppPressable
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
setTimeout(() => {
handleDuplicate(selectedSchedule);
}, 100);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="copy-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Duplicate</Text>
</AppPressable>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Delete */}
<AppPressable
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
// Small delay to ensure modal closes before alert shows
setTimeout(() => {
handleDelete(selectedSchedule);
}, 100);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="trash-outline" size={20} color="#800000" />
<Text className="ml-3 text-base text-[#800000]">Delete</Text>
</AppPressable>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<AppPressable
className="w-full rounded-lg bg-gray-100 p-3"
onPress={() => setShowActionsModal(false)}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</AppPressable>
</View>
</AppPressable>
</AppPressable>
</FullScreenModal>
{/* Delete Confirmation Modal */}
<FullScreenModal
visible={showDeleteModal}
animationType="fade"
onRequestClose={() => !deleting && setShowDeleteModal(false)}
>
<View className="flex-1 items-center justify-center bg-black/50 p-4">
<View className="w-full max-w-sm rounded-2xl bg-white p-6">
{/* Icon */}
<View className="mb-4 items-center">
<View className="h-12 w-12 items-center justify-center rounded-full bg-red-100">
<Ionicons name="trash-outline" size={24} color="#800000" />
</View>
</View>
{/* Title */}
<Text className="mb-2 text-center text-xl font-semibold text-gray-900">
Delete Schedule
</Text>
{/* Description */}
<Text className="mb-6 text-center text-base text-gray-600">
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be
undone.
</Text>
{/* Buttons */}
<View className="flex-row gap-3">
<AppPressable
className="flex-1 rounded-lg bg-gray-100 px-4 py-3"
onPress={() => setShowDeleteModal(false)}
disabled={deleting}
>
<Text className="text-center text-base font-semibold text-gray-700">Cancel</Text>
</AppPressable>
<AppPressable
className={`flex-1 rounded-lg bg-gray-900 px-4 py-3 ${
deleting ? "opacity-50" : ""
}`}
onPress={confirmDelete}
disabled={deleting}
>
<Text className="text-center text-base font-semibold text-white">Delete</Text>
</AppPressable>
</View>
</View>
</View>
</FullScreenModal>
</>
);
}
@@ -0,0 +1,704 @@
import { CalComAPIService, Booking } from "../../services/calcom";
import { showErrorAlert } from "../../utils/alerts";
import { openInAppBrowser } from "../../utils/browser";
import { getDefaultLocationIconUrl, defaultLocations } from "../../utils/defaultLocations";
import { formatAppIdToDisplayName } from "../../utils/formatters";
import { getAppIconUrl } from "../../utils/getAppIconUrl";
import { shadows } from "../../utils/shadows";
import { AppPressable } from "../AppPressable";
import { BookingActionsModal } from "../BookingActionsModal";
import { FullScreenModal } from "../FullScreenModal";
import { SvgImage } from "../SvgImage";
import { Ionicons } from "@expo/vector-icons";
import { GlassView } from "expo-glass-effect";
import { useRouter } from "expo-router";
import React, { useState, useEffect } from "react";
import { View, Text, ScrollView, Alert, ActivityIndicator, TextInput } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
// Format date: "Tuesday, November 25, 2025"
const formatDateFull = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
} catch (error) {
return "";
}
};
// Format time: "9:40pm - 10:00pm"
const formatTime12Hour = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? "pm" : "am";
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const minStr = minutes.toString().padStart(2, "0");
return `${hour12}:${minStr}${period}`;
} catch (error) {
return "";
}
};
// Get timezone from date string
const getTimezone = (dateString: string): string => {
if (!dateString) return "";
try {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return timeZone;
} catch (error) {
return "";
}
};
// Get initials from a name (e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS")
const getInitials = (name: string): string => {
if (!name) return "";
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return "";
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
// Get first letter of first name and first letter of last name
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
};
// Get location provider info
const getLocationProvider = (location: string | undefined, metadata?: Record<string, unknown>) => {
// Check metadata for videoCallUrl first
const videoCallUrl = metadata?.videoCallUrl;
const locationToCheck = videoCallUrl || location;
if (!locationToCheck) return null;
// Check if it's a video call URL
if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) {
// Try to detect provider from URL
if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) {
const iconUrl = getAppIconUrl("daily_video", "cal-video");
return {
label: "Cal Video",
iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg",
url: locationToCheck,
};
}
// Check for other video providers by URL pattern
const videoProviders = [
{ pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" },
{
pattern: /meet\.google\.com/,
label: "Google Meet",
type: "google_video",
appId: "google-meet",
},
{
pattern: /teams\.microsoft\.com/,
label: "Microsoft Teams",
type: "office365_video",
appId: "msteams",
},
];
for (const provider of videoProviders) {
if (provider.pattern.test(locationToCheck)) {
const iconUrl = getAppIconUrl(provider.type, provider.appId);
return {
label: provider.label,
iconUrl: iconUrl,
url: locationToCheck,
};
}
}
// Generic link meeting
const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg";
return {
label: "Link Meeting",
iconUrl: linkIconUrl,
url: locationToCheck,
};
}
// Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video")
if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) {
const appId = locationToCheck.replace("integrations:", "");
const iconUrl = getAppIconUrl("", appId);
if (iconUrl) {
return {
label: formatAppIdToDisplayName(appId),
iconUrl: iconUrl,
url: null,
};
}
}
// Check if it's a default location type
const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck);
if (defaultLocation) {
return {
label: defaultLocation.label,
iconUrl: defaultLocation.iconUrl,
url: null,
};
}
// Fallback: return as plain text location
return {
label: locationToCheck as string,
iconUrl: null,
url: null,
};
};
export interface BookingDetailScreenProps {
uid: string;
}
export function BookingDetailScreen({ uid }: BookingDetailScreenProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(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);
useEffect(() => {
if (uid) {
fetchBooking();
}
}, [uid]);
const fetchBooking = async () => {
try {
setLoading(true);
setError(null);
const bookingData = await CalComAPIService.getBookingByUid(uid);
// Avoid logging booking payloads: they may contain PII (names/emails/notes).
if (__DEV__) {
console.debug("[BookingDetailScreen] booking fetched", {
uid: bookingData.uid,
status: bookingData.status,
hostCount: bookingData.hosts?.length ?? (bookingData.user ? 1 : 0),
attendeeCount: bookingData.attendees?.length ?? 0,
hasRecurringEventId: Boolean(bookingData.recurringEventId),
});
}
setBooking(bookingData);
} catch (err) {
console.error("Error fetching booking");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
console.debug("[BookingDetailScreen] fetchBooking failed", { message, stack });
}
setError("Failed to load booking. Please try again.");
if (__DEV__) {
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} else {
router.back();
}
} finally {
setLoading(false);
}
};
const handleJoinMeeting = () => {
if (!booking?.location) return;
const provider = getLocationProvider(booking.location);
if (provider?.url) {
openInAppBrowser(provider.url, "meeting link");
}
};
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 {
await CalComAPIService.rescheduleBooking(booking.uid, {
start: startUtc,
reschedulingReason: rescheduleReason.trim() || undefined,
});
Alert.alert("Success", "Booking rescheduled successfully");
setShowRescheduleModal(false);
setRescheduleDate("");
setRescheduleTime("");
setRescheduleReason("");
// Refresh booking data
await fetchBooking();
} catch (error) {
console.error("Failed to reschedule booking");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[BookingDetailScreen] rescheduleBooking failed", { message, stack });
}
showErrorAlert("Error", "Failed to reschedule booking. Please try again.");
} finally {
setRescheduling(false);
}
};
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-gray-500">Loading booking...</Text>
</View>
);
}
if (error || !booking) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa] p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{error || "Booking not found"}
</Text>
<AppPressable className="mt-6 rounded-lg bg-black px-6 py-3" onPress={() => router.back()}>
<Text className="text-base font-semibold text-white">Go Back</Text>
</AppPressable>
</View>
);
}
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const dateFormatted = formatDateFull(startTime);
const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
const timezone = getTimezone(startTime);
const locationProvider = getLocationProvider(booking.location, booking.responses);
return (
<View className="flex-1 bg-[#f8f9fa]">
{/* Glass Header */}
<GlassView
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
paddingHorizontal: 16,
paddingBottom: 12,
paddingTop: insets.top + 8,
},
]}
glassEffectStyle="clear"
>
<View className="min-h-[44px] flex-row items-center justify-between">
<AppPressable
className="h-10 w-10 items-center justify-center rounded-full bg-[#F0F0F0]"
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={24} color="#000" />
</AppPressable>
<Text
className="mx-2.5 flex-1 text-center text-lg font-semibold text-black"
numberOfLines={1}
>
{booking?.title || "Booking Details"}
</Text>
<AppPressable
className="h-10 w-10 items-center justify-center rounded-full bg-[#F0F0F0]"
onPress={() => setShowActionsModal(true)}
>
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</AppPressable>
</View>
</GlassView>
<ScrollView
className="flex-1"
style={{ paddingTop: insets.top + 70 }}
contentContainerStyle={{ padding: 16, paddingBottom: 100 }}
>
{/* Title */}
<View className="mb-3">
<Text className="mb-2 text-2xl font-semibold text-[#333]">{booking.title}</Text>
<Text className="text-base text-[#666]">
{dateFormatted} {timeFormatted} ({timezone})
</Text>
</View>
{/* Who Section */}
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Who</Text>
{/* Show host from user field or hosts array */}
{booking.user || (booking.hosts && booking.hosts.length > 0) ? (
<View className="mb-4">
{booking.user ? (
<View className="flex-row items-start">
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(booking.user.name)}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">{booking.user.name}</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
<Text className="text-sm text-[#666]">{booking.user.email}</Text>
</View>
</View>
) : booking.hosts && booking.hosts.length > 0 ? (
booking.hosts.map((host, hostIndex) => (
<View
key={hostIndex}
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}
>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(host.name || "Host")}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{host.name || "Host"}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
{host.email && <Text className="text-sm text-[#666]">{host.email}</Text>}
</View>
</View>
))
) : null}
</View>
) : null}
{booking.attendees && booking.attendees.length > 0 ? (
<View>
{booking.attendees.map((attendee, index) => (
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(attendee.name)}
</Text>
</View>
<View className="flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">{attendee.name}</Text>
<Text className="text-sm text-[#666]">{attendee.email}</Text>
</View>
</View>
))}
</View>
) : null}
</View>
{/* Where Section */}
{locationProvider ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
{locationProvider.url ? (
<AppPressable onPress={handleJoinMeeting} className="flex-row flex-wrap items-center">
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
{locationProvider.url}
</Text>
</AppPressable>
) : (
<View className="flex-row items-center">
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
</View>
)}
</View>
) : null}
{/* Recurring Event Section */}
{booking.recurringEventId ||
(booking as { recurringBookingUid?: string }).recurringBookingUid ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Recurring Event</Text>
<Text className="text-base text-[#666]">Every 2 weeks for 6 occurrences</Text>
</View>
) : null}
{/* Description Section */}
{booking.description ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Description</Text>
<Text className="text-base leading-6 text-[#666]">{booking.description}</Text>
</View>
) : null}
{/* Join Meeting Button */}
{locationProvider?.url ? (
<AppPressable
onPress={handleJoinMeeting}
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
>
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base font-semibold text-white">
Join {locationProvider.label}
</Text>
</AppPressable>
) : null}
</ScrollView>
{/* Booking Actions Modal */}
<BookingActionsModal
visible={showActionsModal}
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
if (__DEV__)
console.debug("[BookingDetailScreen] cancel booking (not implemented yet)");
},
},
]);
}}
/>
{/* Reschedule Modal */}
<FullScreenModal
visible={showRescheduleModal}
animationType="fade"
onRequestClose={() => setShowRescheduleModal(false)}
>
<AppPressable
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
onPress={() => setShowRescheduleModal(false)}
>
<AppPressable
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
onPress={(e) => e.stopPropagation()}
style={shadows.xl()}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<View className="flex-row items-start space-x-3">
<View className="h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#F3F4F6]">
<Ionicons name="calendar-outline" size={24} color="#111827" />
</View>
<View className="flex-1 pt-1">
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
Reschedule Booking
</Text>
<Text className="text-sm text-[#6B7280]">
Select a new date and time for this booking.
</Text>
</View>
</View>
</View>
{/* Content */}
<ScrollView className="max-h-[400px] px-8 pb-6">
{/* Date Input */}
<View className="mb-4">
<Text className="mb-2 text-sm font-bold text-[#111827]">
New Date
<Text className="font-normal text-[#6B7280]"> (YYYY-MM-DD)</Text>
</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="2024-12-25"
placeholderTextColor="#9CA3AF"
value={rescheduleDate}
onChangeText={setRescheduleDate}
editable={!rescheduling}
keyboardType="default"
/>
</View>
{/* Time Input */}
<View className="mb-4">
<Text className="mb-2 text-sm font-bold text-[#111827]">
New Time
<Text className="font-normal text-[#6B7280]"> (HH:MM, 24-hour)</Text>
</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="14:30"
placeholderTextColor="#9CA3AF"
value={rescheduleTime}
onChangeText={setRescheduleTime}
editable={!rescheduling}
keyboardType="default"
/>
</View>
{/* Reason Input */}
<View className="mb-2">
<Text className="mb-2 text-sm font-bold text-[#111827]">
Reason
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
</Text>
<TextInput
className="min-h-[80px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="Enter reason for rescheduling..."
placeholderTextColor="#9CA3AF"
value={rescheduleReason}
onChangeText={setRescheduleReason}
multiline
numberOfLines={3}
textAlignVertical="top"
editable={!rescheduling}
/>
</View>
</ScrollView>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<AppPressable
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
setShowRescheduleModal(false);
setRescheduleDate("");
setRescheduleTime("");
setRescheduleReason("");
}}
disabled={rescheduling}
>
<Text className="text-base font-medium text-[#374151]">Cancel</Text>
</AppPressable>
<AppPressable
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${
rescheduling ? "opacity-60" : ""
}`}
onPress={handleReschedule}
disabled={rescheduling}
>
{rescheduling ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-base font-medium text-white">Reschedule</Text>
)}
</AppPressable>
</View>
</View>
</AppPressable>
</AppPressable>
</FullScreenModal>
</View>
);
}
+41 -5
View File
@@ -36,6 +36,10 @@ interface AuthProviderProps {
// Use the shared secure storage adapter
const storage = secureStorage;
const getErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
const getErrorStack = (error: unknown) => (error instanceof Error ? error.stack : undefined);
export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [accessToken, setAccessToken] = useState<string | null>(null);
@@ -143,7 +147,14 @@ export function AuthProvider({ children }: AuthProviderProps) {
newRefreshToken || refreshToken || undefined
);
} catch (error) {
console.error("Failed to handle token refresh:", error);
const message = getErrorMessage(error);
console.error("Failed to handle token refresh", message);
if (__DEV__) {
console.debug("[AuthContext] token refresh handler failed", {
message,
stack: getErrorStack(error),
});
}
await logout();
}
};
@@ -213,7 +224,14 @@ export function AuthProvider({ children }: AuthProviderProps) {
handleWebSessionAuth();
}
} catch (error) {
console.error("Failed to check auth state:", error);
const message = getErrorMessage(error);
console.error("Failed to check auth state", message);
if (__DEV__) {
console.debug("[AuthContext] checkAuthState failed", {
message,
stack: getErrorStack(error),
});
}
} finally {
setLoading(false);
}
@@ -234,7 +252,14 @@ export function AuthProvider({ children }: AuthProviderProps) {
await setupAfterLogin(tokens.accessToken, tokens.refreshToken);
}
} catch (error) {
console.error("Failed to login from web session:", error);
const message = getErrorMessage(error);
console.error("Failed to login from web session", message);
if (__DEV__) {
console.debug("[AuthContext] loginFromWebSession failed", {
message,
stack: getErrorStack(error),
});
}
throw error;
}
};
@@ -267,7 +292,14 @@ export function AuthProvider({ children }: AuthProviderProps) {
// Clear PKCE parameters
oauthService.clearPKCEParams();
} catch (error) {
console.error("OAuth login failed:", error);
const message = getErrorMessage(error);
console.error("OAuth login failed", message);
if (__DEV__) {
console.debug("[AuthContext] loginWithOAuth failed", {
message,
stack: getErrorStack(error),
});
}
throw error;
} finally {
setLoading(false);
@@ -279,7 +311,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
await clearAuth();
resetAuthState();
} catch (error) {
console.error("Failed to logout:", error);
const message = getErrorMessage(error);
console.error("Failed to logout", message);
if (__DEV__) {
console.debug("[AuthContext] logout failed", { message, stack: getErrorStack(error) });
}
}
};
+12 -12
View File
@@ -17,7 +17,7 @@ export default defineContentScript({
console.log("Cal.com: Gmail integration initialized successfully");
} catch (error) {
// Fail silently - don't break Gmail UI
console.error("Cal.com: Failed to initialize Gmail integration:", error);
console.error("Cal.com: Failed to initialize Gmail integration");
}
}
@@ -29,7 +29,7 @@ export default defineContentScript({
console.log("Cal.com: Google Calendar integration initialized successfully");
} catch (error) {
// Fail silently - don't break Google Calendar UI
console.error("Cal.com: Failed to initialize Google Calendar integration:", error);
console.error("Cal.com: Failed to initialize Google Calendar integration");
}
}
@@ -1536,8 +1536,8 @@ export default defineContentScript({
${datesHTML}
<div style="margin-top: 13px;">
<a href="https://cal.com/${username}/${eventType.slug}?cal.tz=${encodeURIComponent(
timezone
)}" style="text-decoration: none; cursor: pointer; color: #0B57D0; font-size: 14px;">
timezone
)}" style="text-decoration: none; cursor: pointer; color: #0B57D0; font-size: 14px;">
See all available times →
</a>
</div>
@@ -1642,7 +1642,7 @@ export default defineContentScript({
return false;
}
} catch (error) {
console.error("Cal.com: Critical error inserting HTML:", error);
console.error("Cal.com: Critical error inserting HTML");
return false;
}
}
@@ -1871,7 +1871,7 @@ export default defineContentScript({
}, 1000);
} catch (error) {
// Critical failure - log but don't break Gmail
console.error("Cal.com: Failed to initialize chip watcher:", error);
console.error("Cal.com: Failed to initialize chip watcher");
}
}
@@ -2203,7 +2203,7 @@ export default defineContentScript({
embedButton.style.opacity = "1";
embedButton.style.cursor = "pointer";
} catch (error) {
console.error("Cal.com: Failed to insert embed:", error);
console.error("Cal.com: Failed to insert embed");
// Check if this is the "Extension context invalidated" error
const isContextInvalidated =
@@ -2371,7 +2371,7 @@ export default defineContentScript({
window.removeEventListener("resize", updatePosition);
};
} catch (error) {
console.error("Cal.com: Failed to create or insert action bar:", error);
console.error("Cal.com: Failed to create or insert action bar");
// Clean up if something failed
try {
actionBar.remove();
@@ -2381,7 +2381,7 @@ export default defineContentScript({
}
} catch (error) {
// Catch-all to prevent breaking Gmail UI
console.error("Cal.com: Error handling Google chip:", error);
console.error("Cal.com: Error handling Google chip");
return;
}
}
@@ -2458,8 +2458,8 @@ export default defineContentScript({
<div style="font-size: 13px; color: #666; margin-top: 4px;">${
parsedData.slots.length
} time slot${parsedData.slots.length > 1 ? "s" : ""} • ${
parsedData.detectedDuration
}min each</div>
parsedData.detectedDuration
}min each</div>
</div>
<button class="close-menu" style="background: none; border: none; cursor: pointer; font-size: 28px; color: #666; line-height: 1; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background 0.2s ease;">×</button>
`;
@@ -2912,7 +2912,7 @@ export default defineContentScript({
});
});
} catch (error) {
console.error("Error showing Cal.com suggestion menu:", error);
console.error("Error showing Cal.com suggestion menu");
loadingDiv.remove();
const errorMessage = error instanceof Error ? error.message : "";
+2 -2
View File
@@ -543,7 +543,7 @@ function parseAttendees(eventPopup: Element): AttendeeInfo[] {
const whoSection = allText.match(/Who:([\s\S]*?)(?:Where:|When:|$)/i);
if (whoSection) {
const whoText = whoSection[1];
const whoEmails = whoText.match(emailRegex) || [];
const whoEmails: string[] = whoText.match(emailRegex) ?? [];
whoEmails.forEach((email) => {
const emailLower = email.toLowerCase();
@@ -584,7 +584,7 @@ function parseAttendees(eventPopup: Element): AttendeeInfo[] {
guestListContainers.forEach((container) => {
const containerText = container.textContent || "";
const emails = containerText.match(emailRegex) || [];
const emails: string[] = containerText.match(emailRegex) ?? [];
emails.forEach((email) => {
const emailLower = email.toLowerCase();
+1 -1
View File
@@ -176,7 +176,7 @@ export const useBookingActions = ({
Alert.alert("Success", "Event cancelled successfully");
},
onError: (error) => {
console.error("Failed to cancel booking:", error);
console.error("Failed to cancel booking");
showErrorAlert("Error", "Failed to cancel event. Please try again.");
},
}
+4 -4
View File
@@ -108,7 +108,7 @@ export function useCancelBooking() {
});
},
onError: (error) => {
console.error("Failed to cancel booking:", error);
console.error("Failed to cancel booking");
},
});
}
@@ -152,7 +152,7 @@ export function useRescheduleBooking() {
});
},
onError: (error) => {
console.error("Failed to reschedule booking:", error);
console.error("Failed to reschedule booking");
},
});
}
@@ -184,7 +184,7 @@ export function useConfirmBooking() {
});
},
onError: (error) => {
console.error("Failed to confirm booking:", error);
console.error("Failed to confirm booking");
},
});
}
@@ -217,7 +217,7 @@ export function useDeclineBooking() {
});
},
onError: (error) => {
console.error("Failed to decline booking:", error);
console.error("Failed to decline booking");
},
});
}
+24 -4
View File
@@ -93,7 +93,12 @@ export function useCreateEventType() {
queryClient.setQueryData(queryKeys.eventTypes.detail(newEventType.id), newEventType);
},
onError: (error) => {
console.error("Failed to create event type:", error);
console.error("Failed to create event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useCreateEventType] failed", { message, stack });
}
},
});
}
@@ -127,7 +132,12 @@ export function useUpdateEventType() {
queryClient.setQueryData(queryKeys.eventTypes.detail(variables.id), updatedEventType);
},
onError: (error) => {
console.error("Failed to update event type:", error);
console.error("Failed to update event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useUpdateEventType] failed", { message, stack });
}
},
});
}
@@ -173,7 +183,12 @@ export function useDeleteEventType() {
if (context?.previousEventTypes) {
queryClient.setQueryData(queryKeys.eventTypes.lists(), context.previousEventTypes);
}
console.error("Failed to delete event type:", error);
console.error("Failed to delete event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useDeleteEventType] failed", { message, stack });
}
},
onSettled: () => {
// Always refetch after error or success
@@ -233,7 +248,12 @@ export function useDuplicateEventType() {
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
},
onError: (error) => {
console.error("Failed to duplicate event type:", error);
console.error("Failed to duplicate event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useDuplicateEventType] failed", { message, stack });
}
},
});
}
+5 -5
View File
@@ -147,7 +147,7 @@ export function useCreateSchedule() {
queryClient.setQueryData(queryKeys.schedules.detail(newSchedule.id), newSchedule);
},
onError: (error) => {
console.error("Failed to create schedule:", error);
console.error("Failed to create schedule");
},
});
}
@@ -181,7 +181,7 @@ export function useUpdateSchedule() {
queryClient.setQueryData(queryKeys.schedules.detail(variables.id), updatedSchedule);
},
onError: (error) => {
console.error("Failed to update schedule:", error);
console.error("Failed to update schedule");
},
});
}
@@ -208,7 +208,7 @@ export function useSetScheduleAsDefault() {
queryClient.invalidateQueries({ queryKey: queryKeys.schedules.all });
},
onError: (error) => {
console.error("Failed to set schedule as default:", error);
console.error("Failed to set schedule as default");
},
});
}
@@ -252,7 +252,7 @@ export function useDeleteSchedule() {
if (context?.previousSchedules) {
queryClient.setQueryData(queryKeys.schedules.lists(), context.previousSchedules);
}
console.error("Failed to delete schedule:", error);
console.error("Failed to delete schedule");
},
onSettled: () => {
// Always refetch after error or success
@@ -283,7 +283,7 @@ export function useDuplicateSchedule() {
queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() });
},
onError: (error) => {
console.error("Failed to duplicate schedule:", error);
console.error("Failed to duplicate schedule");
},
});
}
+6 -1
View File
@@ -114,7 +114,12 @@ export function useUpdateUserProfile() {
if (context?.previousProfile) {
queryClient.setQueryData(queryKeys.userProfile.current(), context.previousProfile);
}
console.error("Failed to update user profile:", error);
console.error("Failed to update user profile");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useUpdateUserProfile] failed", { message, stack });
}
},
onSettled: () => {
// Always refetch after error or success
+2 -1
View File
@@ -1,13 +1,14 @@
{
"name": "cal-companion",
"displayName": "Cal.com Companion",
"version": "1.7.4",
"version": "1.7.5",
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"typecheck": "tsc -p tsconfig.json --noEmit",
"ext": "wxt",
"ext:build": "wxt build",
"ext:build-prod": "BUILD_FOR_STORE=true wxt build",
+28 -28
View File
@@ -191,7 +191,7 @@ export class CalComAPIService {
throw new Error("Invalid response from update user profile API");
} catch (error) {
console.error("updateUserProfile error:", error);
console.error("updateUserProfile error");
throw error;
}
}
@@ -321,7 +321,7 @@ export class CalComAPIService {
"2024-06-14"
);
} catch (error) {
console.error("Delete API error:", error);
console.error("Delete API error");
throw error;
}
}
@@ -349,7 +349,7 @@ export class CalComAPIService {
throw new Error("Invalid response from create event type API");
} catch (error) {
console.error("createEventType error:", error);
console.error("createEventType error");
throw error;
}
}
@@ -406,7 +406,7 @@ export class CalComAPIService {
throw new Error("Invalid response from reschedule booking API");
} catch (error) {
console.error("rescheduleBooking error:", error);
console.error("rescheduleBooking error");
throw error;
}
}
@@ -431,7 +431,7 @@ export class CalComAPIService {
throw new Error("Invalid response from confirm booking API");
} catch (error) {
console.error("confirmBooking error:", error);
console.error("confirmBooking error");
throw error;
}
}
@@ -462,7 +462,7 @@ export class CalComAPIService {
throw new Error("Invalid response from decline booking API");
} catch (error) {
console.error("declineBooking error:", error);
console.error("declineBooking error");
throw error;
}
}
@@ -502,7 +502,7 @@ export class CalComAPIService {
throw new Error("Invalid response from mark absent API");
} catch (error) {
console.error("markAbsent error:", error);
console.error("markAbsent error");
throw error;
}
}
@@ -589,7 +589,7 @@ export class CalComAPIService {
}
throw new Error("Invalid response from get booking API");
} catch (error) {
console.error("getBookingByUid error:", error);
console.error("getBookingByUid error");
throw error;
}
}
@@ -775,7 +775,7 @@ export class CalComAPIService {
throw new Error("Invalid response from create schedule API");
} catch (error) {
console.error("createSchedule error:", error);
console.error("createSchedule error");
throw error;
}
}
@@ -802,7 +802,7 @@ export class CalComAPIService {
return null;
} catch (error) {
console.error("getScheduleById error:", error);
console.error("getScheduleById error");
throw error;
}
}
@@ -820,7 +820,7 @@ export class CalComAPIService {
return [];
} catch (error) {
console.error("getConferencingOptions error:", error);
console.error("getConferencingOptions error");
throw error;
}
}
@@ -849,7 +849,7 @@ export class CalComAPIService {
return null;
}
}
console.error("getEventTypeById error:", error);
console.error("getEventTypeById error");
throw error;
}
}
@@ -946,7 +946,7 @@ export class CalComAPIService {
throw new Error("Invalid response from update event type API");
} catch (error) {
console.error("updateEventType error:", error);
console.error("updateEventType error");
throw error;
}
}
@@ -992,7 +992,7 @@ export class CalComAPIService {
throw new Error("Invalid response from update schedule API");
} catch (error) {
console.error("updateSchedule error:", error);
console.error("updateSchedule error");
throw error;
}
}
@@ -1017,7 +1017,7 @@ export class CalComAPIService {
throw new Error("Invalid response from duplicate schedule API");
} catch (error) {
console.error("duplicateSchedule error:", error);
console.error("duplicateSchedule error");
throw error;
}
}
@@ -1036,7 +1036,7 @@ export class CalComAPIService {
"2024-06-11"
);
} catch (error) {
console.error("deleteSchedule error:", error);
console.error("deleteSchedule error");
throw error;
}
}
@@ -1056,7 +1056,7 @@ export class CalComAPIService {
return [];
} catch (error) {
console.error("getWebhooks error:", error);
console.error("getWebhooks error");
throw error;
}
}
@@ -1079,7 +1079,7 @@ export class CalComAPIService {
throw new Error("Invalid response from create webhook API");
} catch (error) {
console.error("createWebhook error:", error);
console.error("createWebhook error");
throw error;
}
}
@@ -1105,7 +1105,7 @@ export class CalComAPIService {
throw new Error("Invalid response from update webhook API");
} catch (error) {
console.error("updateWebhook error:", error);
console.error("updateWebhook error");
throw error;
}
}
@@ -1117,7 +1117,7 @@ export class CalComAPIService {
method: "DELETE",
});
} catch (error) {
console.error("deleteWebhook error:", error);
console.error("deleteWebhook error");
throw error;
}
}
@@ -1135,7 +1135,7 @@ export class CalComAPIService {
return [];
} catch (error) {
console.error("getEventTypeWebhooks error:", error);
console.error("getEventTypeWebhooks error");
throw error;
}
}
@@ -1164,7 +1164,7 @@ export class CalComAPIService {
throw new Error("Invalid response from create event type webhook API");
} catch (error) {
console.error("createEventTypeWebhook error:", error);
console.error("createEventTypeWebhook error");
throw error;
}
}
@@ -1194,7 +1194,7 @@ export class CalComAPIService {
throw new Error("Invalid response from update event type webhook API");
} catch (error) {
console.error("updateEventTypeWebhook error:", error);
console.error("updateEventTypeWebhook error");
throw error;
}
}
@@ -1206,7 +1206,7 @@ export class CalComAPIService {
method: "DELETE",
});
} catch (error) {
console.error("deleteEventTypeWebhook error:", error);
console.error("deleteEventTypeWebhook error");
throw error;
}
}
@@ -1228,7 +1228,7 @@ export class CalComAPIService {
return [];
} catch (error) {
console.error("getEventTypePrivateLinks error:", error);
console.error("getEventTypePrivateLinks error");
throw error;
}
}
@@ -1257,7 +1257,7 @@ export class CalComAPIService {
throw new Error("Invalid response from create private link API");
} catch (error) {
console.error("createEventTypePrivateLink error:", error);
console.error("createEventTypePrivateLink error");
throw error;
}
}
@@ -1287,7 +1287,7 @@ export class CalComAPIService {
throw new Error("Invalid response from update private link API");
} catch (error) {
console.error("updateEventTypePrivateLink error:", error);
console.error("updateEventTypePrivateLink error");
throw error;
}
}
@@ -1299,7 +1299,7 @@ export class CalComAPIService {
method: "DELETE",
});
} catch (error) {
console.error("deleteEventTypePrivateLink error:", error);
console.error("deleteEventTypePrivateLink error");
throw error;
}
}
+6 -1
View File
@@ -57,7 +57,12 @@ export const openInAppBrowser = async (
await WebBrowser.openBrowserAsync(url, browserOptions);
} catch (error) {
console.error(`Failed to open ${url}:`, error);
console.error("Failed to open link");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[openInAppBrowser] failed", { message, stack, fallbackMessage });
}
showErrorAlert("Error", `Failed to open ${fallbackMessage || "link"}. Please try again.`);
}
};
+6 -1
View File
@@ -149,7 +149,12 @@ export function parseGoogleChip(chipElement: HTMLElement): ParsedGoogleChip | nu
detectedDuration,
};
} catch (error) {
console.error("Error parsing Google chip:", error);
console.error("Error parsing Google chip");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[gmailGoogleChipParser] parse failed", { message, stack });
}
return null;
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ export { showErrorAlert } from "./alerts";
export { openInAppBrowser } from "./browser";
// Network utilities
export { isOnline, subscribeToNetworkChanges } from "./network";
export { isOnline } from "./network";
// Slug utilities
export { slugify } from "./slugify";
+2 -2
View File
@@ -58,7 +58,7 @@ export default defineConfig({
outDir: ".output",
manifest: {
name: "Cal.com Companion",
version: "1.7.4",
version: "1.7.5",
description: "Your calendar companion for quick booking and scheduling",
permissions: ["activeTab", "storage", "identity"],
host_permissions: [
@@ -68,7 +68,7 @@ export default defineConfig({
"https://mail.google.com/*",
"https://calendar.google.com/*",
// Include localhost permission for dev builds (needed for iframe to load)
...(!isBuildForStore ? ["http://localhost:*/*"] : []),
// ...(!isBuildForStore ? ["http://localhost:*/*"] : []),
],
content_security_policy: {
extension_pages: !isBuildForStore