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:
co-authored by
Peer Richelsen
parent
9909ac53fc
commit
6650fdb9ef
+12
-2
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1678,7 +1678,7 @@ export default function EventTypeDetail() {
|
||||
schedulesLoading={schedulesLoading}
|
||||
scheduleDetailsLoading={scheduleDetailsLoading}
|
||||
selectedScheduleDetails={selectedScheduleDetails}
|
||||
getDaySchedule={getDaySchedule}
|
||||
getDaySchedules={getDaySchedule}
|
||||
formatTime={formatTime}
|
||||
selectedTimezone={selectedTimezone}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
@@ -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
@@ -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;
|
||||
@@ -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.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 : "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.");
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user