feat(companion): iOS native UI components and simplified event type creation (#26240)
ios: https://github.com/user-attachments/assets/efdfd3e3-239f-4cbf-be1a-7784d1df1851 android: https://github.com/user-attachments/assets/a8e22a5c-60f3-4fb6-8923-b8a786bceb0c ## Overview This PR introduces native iOS UI components, refactors the profile menu, and simplifies the event type creation flow for a more native iOS experience. ## Key Changes ### iOS Native UI Components - **iOS-specific Event Types Page**: Added native Stack.Header, glass UI, and context menus for iOS - **Platform-specific Profile Sheet**: Created separate implementations for iOS (formSheet) and Android/Web (modal) - **Bottom Glass UI Navbar**: Updated bottom navigation bar with glass UI styling for iOS - **Native iOS Alerts**: Replaced custom modals with native `Alert.prompt` for event types and availability pages ### Profile Menu Refactor - Restructured More page with folder layout and native iOS header support - Added profile button to More page header on iOS with glass UI - Added "Copy public page link" and "Roadmap" options to profile sheet - Refactored Header component: removed inline profile modal, now uses route-based navigation - Updated Availability page with New menu and profile button in header - Fixed "My Settings" URL to use `/general` endpoint - Removed "Profile" item from More section menu - Updated tab layout to support `(more)` folder structure ### Simplified Event Type Creation - Reduced event type creation to only require title input - Auto-generate slug from title using slugify utility - Set default duration to 15 minutes - Leave description empty (users can edit later) - Applied to both iOS and Android/Web platforms ### UI Improvements - Updated login button styling: changed background color to pure black (#000000) with white text for better contrast
This commit is contained in:
+5
-1
@@ -19,6 +19,9 @@
|
||||
"bundleIdentifier": "com.calcom.companion",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"entitlements": {
|
||||
"com.apple.developer.default-data-protection": "NSFileProtectionComplete"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
@@ -28,7 +31,8 @@
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.calcom.companion",
|
||||
"softwareKeyboardLayoutMode": "pan"
|
||||
"softwareKeyboardLayoutMode": "pan",
|
||||
"usesCleartextTraffic": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png",
|
||||
|
||||
@@ -1,15 +1,86 @@
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack } from "expo-router";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { AvailabilityListScreen } from "@/components/screens/AvailabilityListScreen";
|
||||
import { useCreateSchedule } from "@/hooks";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
export default function Availability() {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const { mutate: createScheduleMutation } = useCreateSchedule();
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setShowCreateModal(true);
|
||||
// Use native iOS Alert.prompt for a native look
|
||||
Alert.prompt(
|
||||
"Add a new schedule",
|
||||
"Create a new availability schedule.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Continue",
|
||||
onPress: async (name?: string) => {
|
||||
if (!name?.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 {
|
||||
console.log("Could not get user timezone, using default");
|
||||
}
|
||||
|
||||
// Create schedule with Monday-Friday 9 AM - 5 PM default
|
||||
createScheduleMutation(
|
||||
{
|
||||
name: name.trim(),
|
||||
timeZone: userTimezone,
|
||||
isDefault: false,
|
||||
availability: [
|
||||
{
|
||||
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
onSuccess: (newSchedule) => {
|
||||
// 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("[Availability] createSchedule failed", { message, stack });
|
||||
}
|
||||
showErrorAlert("Error", "Failed to create schedule. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
"plain-text",
|
||||
"",
|
||||
"default"
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -21,8 +92,18 @@ export default function Availability() {
|
||||
>
|
||||
<Stack.Header.Title large>Availability</Stack.Header.Title>
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button onPress={handleCreateNew} tintColor="#000" variant="prominent">
|
||||
New
|
||||
{/* New Menu */}
|
||||
<Stack.Header.Menu>
|
||||
<Stack.Header.Icon sf="plus" />
|
||||
|
||||
<Stack.Header.MenuAction icon="clock" onPress={handleCreateNew}>
|
||||
New Availability
|
||||
</Stack.Header.MenuAction>
|
||||
</Stack.Header.Menu>
|
||||
|
||||
{/* Profile Button */}
|
||||
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
|
||||
<Stack.Header.Icon sf="person.circle.fill" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
<Stack.Header.SearchBar
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Header } from "@/components/Header";
|
||||
import { useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
|
||||
import type { EventType } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
export default function Bookings() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -37,7 +38,7 @@ export default function Bookings() {
|
||||
setEventTypes(types);
|
||||
setEventTypesLoading(false);
|
||||
} catch (err) {
|
||||
console.error("Error fetching event types:", err);
|
||||
safeLogError("Error fetching event types:", err);
|
||||
// Error is logged but not displayed to user for event type filter
|
||||
setEventTypesLoading(false);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import type { BookingRecording } from "@/services/types/bookings.types";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
export default function ViewRecordings() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
@@ -17,7 +18,7 @@ export default function ViewRecordings() {
|
||||
CalComAPIService.getRecordings(uid)
|
||||
.then(setRecordings)
|
||||
.catch((error) => {
|
||||
console.error("Failed to load recordings:", error);
|
||||
safeLogError("Failed to load recordings:", error);
|
||||
Alert.alert("Error", "Failed to load recordings");
|
||||
router.back();
|
||||
})
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
import { Button, ContextMenu, Host, HStack, Image as SwiftUIImage } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActionSheetIOS,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Share,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { EmptyScreen } from "@/components/EmptyScreen";
|
||||
import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import { LoadingSpinner } from "@/components/LoadingSpinner";
|
||||
import {
|
||||
useCreateEventType,
|
||||
useDeleteEventType,
|
||||
useDuplicateEventType,
|
||||
useEventTypes,
|
||||
} from "@/hooks";
|
||||
import { CalComAPIService, type EventType } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import { getEventDuration } from "@/utils/getEventDuration";
|
||||
import { offlineAwareRefresh } from "@/utils/network";
|
||||
import { normalizeMarkdown } from "@/utils/normalizeMarkdown";
|
||||
import { slugify } from "@/utils/slugify";
|
||||
|
||||
export default function EventTypesIOS() {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// No modal state needed for iOS - using native Alert.prompt
|
||||
|
||||
// Use React Query hooks
|
||||
const {
|
||||
data: eventTypes = [],
|
||||
isLoading: loading,
|
||||
isFetching,
|
||||
error: queryError,
|
||||
refetch,
|
||||
} = useEventTypes();
|
||||
|
||||
// Show refresh indicator when fetching
|
||||
const refreshing = isFetching && !loading;
|
||||
|
||||
const { mutate: createEventTypeMutation } = useCreateEventType();
|
||||
const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType();
|
||||
const { mutate: duplicateEventTypeMutation } = useDuplicateEventType();
|
||||
|
||||
// Convert query error to string
|
||||
// Don't show error UI for authentication errors (user will be redirected to login)
|
||||
// Only show error UI in development mode for other errors
|
||||
const isAuthError =
|
||||
queryError?.message?.includes("Authentication") ||
|
||||
queryError?.message?.includes("sign in") ||
|
||||
queryError?.message?.includes("401");
|
||||
const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null;
|
||||
|
||||
// Modal state for delete confirmation
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [eventTypeToDelete, setEventTypeToDelete] = useState<EventType | null>(null);
|
||||
|
||||
// Handle pull-to-refresh (offline-aware)
|
||||
const onRefresh = () => offlineAwareRefresh(refetch);
|
||||
|
||||
// Filter event types based on search query
|
||||
const filteredEventTypes = useMemo(() => {
|
||||
if (searchQuery.trim() === "") {
|
||||
return eventTypes;
|
||||
}
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return eventTypes.filter(
|
||||
(eventType) =>
|
||||
eventType.title.toLowerCase().includes(searchLower) ||
|
||||
eventType.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [eventTypes, searchQuery]);
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
};
|
||||
|
||||
const handleEventTypePress = (eventType: EventType) => {
|
||||
handleEdit(eventType);
|
||||
};
|
||||
|
||||
const handleEventTypeLongPress = (eventType: EventType) => {
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options: ["Cancel", "Edit", "Duplicate", "Delete"],
|
||||
destructiveButtonIndex: 3, // Delete button
|
||||
cancelButtonIndex: 0,
|
||||
title: eventType.title,
|
||||
message: eventType.description ? normalizeMarkdown(eventType.description) : undefined,
|
||||
},
|
||||
(buttonIndex) => {
|
||||
switch (buttonIndex) {
|
||||
case 1: // Edit
|
||||
handleEdit(eventType);
|
||||
break;
|
||||
case 2: // Duplicate
|
||||
handleDuplicate(eventType);
|
||||
break;
|
||||
case 3: // Delete
|
||||
handleDelete(eventType);
|
||||
break;
|
||||
default:
|
||||
// Cancel - do nothing
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyLink = async (eventType: EventType) => {
|
||||
try {
|
||||
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
|
||||
await Clipboard.setStringAsync(link);
|
||||
Alert.alert("Link Copied", "Event type link copied!");
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to copy link. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const _handleShare = async (eventType: EventType) => {
|
||||
try {
|
||||
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
|
||||
await Share.share({
|
||||
message: `Book a meeting: ${eventType.title}`,
|
||||
url: link,
|
||||
});
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to share link. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (eventType: EventType) => {
|
||||
const duration = getEventDuration(eventType);
|
||||
router.push({
|
||||
pathname: "/event-type-detail",
|
||||
params: {
|
||||
id: eventType.id.toString(),
|
||||
title: eventType.title,
|
||||
description: eventType.description || "",
|
||||
duration: duration.toString(),
|
||||
price: eventType.price?.toString() || "",
|
||||
currency: eventType.currency || "",
|
||||
slug: eventType.slug || "",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (eventType: EventType) => {
|
||||
setEventTypeToDelete(eventType);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!eventTypeToDelete) return;
|
||||
|
||||
deleteEventTypeMutation(eventTypeToDelete.id, {
|
||||
onSuccess: () => {
|
||||
// Close modal and reset state
|
||||
setShowDeleteModal(false);
|
||||
setEventTypeToDelete(null);
|
||||
Alert.alert("Success", "Event type deleted successfully");
|
||||
},
|
||||
onError: (deleteError) => {
|
||||
const message = deleteError instanceof Error ? deleteError.message : String(deleteError);
|
||||
console.error("Failed to delete event type", message);
|
||||
if (__DEV__) {
|
||||
const stack = deleteError instanceof Error ? deleteError.stack : undefined;
|
||||
console.debug("[EventTypes] deleteEventType failed", { message, stack });
|
||||
}
|
||||
showErrorAlert("Error", "Failed to delete event type. Please try again.");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicate = (eventType: EventType) => {
|
||||
duplicateEventTypeMutation(
|
||||
{ eventType, existingEventTypes: eventTypes },
|
||||
{
|
||||
onSuccess: (duplicatedEventType) => {
|
||||
Alert.alert("Success", "Event type duplicated successfully");
|
||||
|
||||
const duration = getEventDuration(eventType);
|
||||
|
||||
// Navigate to edit the newly created duplicate
|
||||
router.push({
|
||||
pathname: "/event-type-detail",
|
||||
params: {
|
||||
id: duplicatedEventType.id.toString(),
|
||||
title: duplicatedEventType.title,
|
||||
description: duplicatedEventType.description || "",
|
||||
duration: (
|
||||
duplicatedEventType.lengthInMinutes ||
|
||||
duplicatedEventType.length ||
|
||||
duration
|
||||
).toString(),
|
||||
slug: duplicatedEventType.slug || "",
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (duplicateError) => {
|
||||
const message =
|
||||
duplicateError instanceof Error ? duplicateError.message : String(duplicateError);
|
||||
console.error("Failed to duplicate event type", message);
|
||||
if (__DEV__) {
|
||||
const stack = duplicateError instanceof Error ? duplicateError.stack : undefined;
|
||||
console.debug("[EventTypes] duplicateEventType failed", { message, stack });
|
||||
}
|
||||
showErrorAlert("Error", "Failed to duplicate event type. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handlePreview = async (eventType: EventType) => {
|
||||
try {
|
||||
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
|
||||
// For mobile, use in-app browser
|
||||
await openInAppBrowser(link, "event type preview");
|
||||
} catch {
|
||||
console.error("Failed to open preview");
|
||||
showErrorAlert("Error", "Failed to open preview. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
// Use native iOS Alert.prompt for a native look
|
||||
Alert.prompt(
|
||||
"Add a new event type",
|
||||
"Set up event types to offer different types of meetings.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Continue",
|
||||
onPress: (title?: string) => {
|
||||
if (!title?.trim()) {
|
||||
Alert.alert("Error", "Please enter a title for your event type");
|
||||
return;
|
||||
}
|
||||
|
||||
const autoSlug = slugify(title.trim());
|
||||
if (!autoSlug) {
|
||||
Alert.alert("Error", "Title must contain at least one letter or number");
|
||||
return;
|
||||
}
|
||||
|
||||
createEventTypeMutation(
|
||||
{
|
||||
title: title.trim(),
|
||||
slug: autoSlug,
|
||||
lengthInMinutes: 15, // Default duration
|
||||
description: undefined, // Empty description
|
||||
},
|
||||
{
|
||||
onSuccess: (newEventType) => {
|
||||
// Navigate to edit the newly created event type
|
||||
router.push({
|
||||
pathname: "/event-type-detail",
|
||||
params: {
|
||||
id: newEventType.id.toString(),
|
||||
title: newEventType.title,
|
||||
description: newEventType.description || "",
|
||||
duration: (
|
||||
newEventType.lengthInMinutes ||
|
||||
newEventType.length ||
|
||||
15
|
||||
).toString(),
|
||||
slug: newEventType.slug || "",
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (createError) => {
|
||||
const message =
|
||||
createError instanceof Error ? createError.message : String(createError);
|
||||
console.error("Failed to create event type", message);
|
||||
if (__DEV__) {
|
||||
const stack = createError instanceof Error ? createError.stack : undefined;
|
||||
console.debug("[EventTypes] createEventType failed", { message, stack });
|
||||
}
|
||||
showErrorAlert("Error", "Failed to create event type. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
"plain-text",
|
||||
"",
|
||||
"default"
|
||||
);
|
||||
};
|
||||
|
||||
// Sort by menu handler (dummy for now)
|
||||
const handleSortByOption = (option: string) => {
|
||||
console.log("Sort by:", option);
|
||||
// TODO: Implement actual sorting logic
|
||||
};
|
||||
|
||||
// Filter menu handler (dummy for now)
|
||||
const handleFilterOption = (option: string) => {
|
||||
console.log("Filter by:", option);
|
||||
// TODO: Implement actual filtering logic
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<LoadingSpinner size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 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">
|
||||
Unable to load event types
|
||||
</Text>
|
||||
<Text className="mb-6 text-center text-base text-gray-500">{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>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventTypes.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Event Types",
|
||||
headerBlurEffect: isLiquidGlassAvailable() ? undefined : "light",
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
headerLargeTitleEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
|
||||
<EmptyScreen
|
||||
icon="link-outline"
|
||||
headline="Create your first event type"
|
||||
description="Event types enable you to share links that show available times on your calendar and allow people to make bookings with you."
|
||||
buttonText="New"
|
||||
onButtonPress={handleOpenCreateModal}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* iOS Native Header with Glass UI */}
|
||||
<Stack.Header
|
||||
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
|
||||
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
|
||||
>
|
||||
<Stack.Header.Title large>Event Types</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
{/* Filter/Sort Menu */}
|
||||
<Stack.Header.Menu>
|
||||
<Stack.Header.Icon sf="line.3.horizontal.decrease" />
|
||||
|
||||
{/* Sort by Submenu - opens as separate submenu */}
|
||||
<Stack.Header.Menu title="Sort by">
|
||||
<Stack.Header.MenuAction
|
||||
icon="textformat.abc"
|
||||
onPress={() => handleSortByOption("alphabetical")}
|
||||
>
|
||||
Alphabetical
|
||||
</Stack.Header.MenuAction>
|
||||
<Stack.Header.MenuAction
|
||||
icon="calendar.badge.clock"
|
||||
onPress={() => handleSortByOption("newest")}
|
||||
>
|
||||
Newest First
|
||||
</Stack.Header.MenuAction>
|
||||
<Stack.Header.MenuAction icon="clock" onPress={() => handleSortByOption("duration")}>
|
||||
By Duration
|
||||
</Stack.Header.MenuAction>
|
||||
</Stack.Header.Menu>
|
||||
|
||||
{/* Filter Submenu - opens as separate submenu */}
|
||||
<Stack.Header.Menu title="Filter">
|
||||
<Stack.Header.MenuAction
|
||||
icon="checkmark.circle"
|
||||
onPress={() => handleFilterOption("all")}
|
||||
>
|
||||
All Event Types
|
||||
</Stack.Header.MenuAction>
|
||||
<Stack.Header.MenuAction icon="eye" onPress={() => handleFilterOption("active")}>
|
||||
Active Only
|
||||
</Stack.Header.MenuAction>
|
||||
<Stack.Header.MenuAction
|
||||
icon="dollarsign.circle"
|
||||
onPress={() => handleFilterOption("paid")}
|
||||
>
|
||||
Paid Events
|
||||
</Stack.Header.MenuAction>
|
||||
</Stack.Header.Menu>
|
||||
</Stack.Header.Menu>
|
||||
|
||||
{/* Profile Button - Opens bottom sheet */}
|
||||
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
|
||||
<Stack.Header.Icon sf="person.circle.fill" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
|
||||
{/* Search Bar */}
|
||||
<Stack.Header.SearchBar
|
||||
placeholder="Search event types"
|
||||
onChangeText={(e) => handleSearch(e.nativeEvent.text)}
|
||||
obscureBackground={false}
|
||||
barTintColor="#fff"
|
||||
/>
|
||||
</Stack.Header>
|
||||
|
||||
{/* Event Types List */}
|
||||
<ScrollView
|
||||
style={{ backgroundColor: "white" }}
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
{filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? (
|
||||
<View className="flex-1 items-center justify-center bg-gray-50 p-5 pt-20">
|
||||
<EmptyScreen
|
||||
icon="search-outline"
|
||||
headline={`No results found for "${searchQuery}"`}
|
||||
description="Try searching with different keywords"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="px-2 pt-4 md:px-4">
|
||||
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
{filteredEventTypes.map((item, index) => (
|
||||
<EventTypeListItem
|
||||
key={item.id.toString()}
|
||||
item={item}
|
||||
index={index}
|
||||
filteredEventTypes={filteredEventTypes}
|
||||
copiedEventTypeId={null}
|
||||
handleEventTypePress={handleEventTypePress}
|
||||
handleEventTypeLongPress={handleEventTypeLongPress}
|
||||
handleCopyLink={handleCopyLink}
|
||||
handlePreview={handlePreview}
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Floating Action Button for New Event Type with Glass UI Menu */}
|
||||
<View className="absolute right-6" style={{ bottom: 100 }}>
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
<Button systemImage="link" onPress={handleOpenCreateModal} label="New Event Type" />
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack modifiers={[frame({ width: 35, height: 40 })]}>
|
||||
<SwiftUIImage
|
||||
systemName="plus"
|
||||
color="primary"
|
||||
size={28}
|
||||
// modifiers={[frame({ width: 56, height: 56 })]}
|
||||
/>
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<FullScreenModal
|
||||
visible={showDeleteModal}
|
||||
animationType="fade"
|
||||
onRequestClose={() => {
|
||||
if (!isDeleting) {
|
||||
setShowDeleteModal(false);
|
||||
setEventTypeToDelete(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View className="flex-1 items-center justify-center bg-black/50 p-4">
|
||||
<View className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||
{/* Header with icon and title */}
|
||||
<View className="p-6">
|
||||
<View className="flex-row">
|
||||
{/* Danger icon */}
|
||||
<View className="mr-3 self-start rounded-full bg-red-50 p-2">
|
||||
<Ionicons name="alert-circle" size={20} color="#800000" />
|
||||
</View>
|
||||
|
||||
{/* Title and description */}
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-900">
|
||||
Delete Event Type
|
||||
</Text>
|
||||
<Text className="text-sm leading-5 text-gray-600">
|
||||
{eventTypeToDelete ? (
|
||||
<>
|
||||
This will permanently delete the "{eventTypeToDelete.title}" event type.
|
||||
This action cannot be undone.
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer with buttons */}
|
||||
<View className="flex-row-reverse gap-2 px-6 pb-6 pt-2">
|
||||
<TouchableOpacity
|
||||
className={`rounded-lg bg-gray-900 px-4 py-2.5 ${isDeleting ? "opacity-50" : ""}`}
|
||||
onPress={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-white">Delete</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2.5"
|
||||
onPress={() => {
|
||||
setShowDeleteModal(false);
|
||||
setEventTypeToDelete(null);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
useDeleteEventType,
|
||||
useDuplicateEventType,
|
||||
useEventTypes,
|
||||
useUsername,
|
||||
} from "@/hooks";
|
||||
import { CalComAPIService, type EventType } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
@@ -43,10 +42,6 @@ export default function EventTypes() {
|
||||
// Modal state for creating new event type
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newEventTitle, setNewEventTitle] = useState("");
|
||||
const [newEventSlug, setNewEventSlug] = useState("");
|
||||
const [newEventDescription, setNewEventDescription] = useState("");
|
||||
const [newEventDuration, setNewEventDuration] = useState("15");
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
// Use React Query hooks
|
||||
const {
|
||||
@@ -60,7 +55,6 @@ export default function EventTypes() {
|
||||
// Show refresh indicator when fetching
|
||||
const refreshing = isFetching && !loading;
|
||||
|
||||
const { data: username = "" } = useUsername();
|
||||
const { mutate: createEventTypeMutation, isPending: creating } = useCreateEventType();
|
||||
const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType();
|
||||
const { mutate: duplicateEventTypeMutation } = useDuplicateEventType();
|
||||
@@ -334,10 +328,6 @@ export default function EventTypes() {
|
||||
const handleCloseCreateModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setNewEventTitle("");
|
||||
setNewEventSlug("");
|
||||
setNewEventDescription("");
|
||||
setNewEventDuration("15");
|
||||
setIsSlugManuallyEdited(false);
|
||||
};
|
||||
|
||||
const handleCreateEventType = () => {
|
||||
@@ -346,23 +336,18 @@ export default function EventTypes() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newEventSlug.trim()) {
|
||||
Alert.alert("Error", "Please enter a URL for your event type");
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = parseInt(newEventDuration, 10);
|
||||
if (Number.isNaN(duration) || duration <= 0) {
|
||||
Alert.alert("Error", "Please enter a valid duration");
|
||||
const autoSlug = slugify(newEventTitle.trim());
|
||||
if (!autoSlug) {
|
||||
Alert.alert("Error", "Title must contain at least one letter or number");
|
||||
return;
|
||||
}
|
||||
|
||||
createEventTypeMutation(
|
||||
{
|
||||
title: newEventTitle.trim(),
|
||||
slug: newEventSlug.trim(),
|
||||
lengthInMinutes: duration,
|
||||
description: newEventDescription.trim() || undefined,
|
||||
slug: autoSlug,
|
||||
lengthInMinutes: 15, // Default duration
|
||||
description: undefined, // Empty description
|
||||
},
|
||||
{
|
||||
onSuccess: (newEventType) => {
|
||||
@@ -580,7 +565,7 @@ export default function EventTypes() {
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView className="px-8 pb-6" showsVerticalScrollIndicator={false}>
|
||||
<View className="px-8 pb-6">
|
||||
{/* Title */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-medium text-[#374151]">Title</Text>
|
||||
@@ -589,74 +574,14 @@ export default function EventTypes() {
|
||||
placeholder="Quick Chat"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={newEventTitle}
|
||||
onChangeText={(text) => {
|
||||
setNewEventTitle(text);
|
||||
// Auto-generate slug from title if user hasn't manually edited it
|
||||
if (!isSlugManuallyEdited) {
|
||||
setNewEventSlug(slugify(text, true));
|
||||
}
|
||||
}}
|
||||
onChangeText={setNewEventTitle}
|
||||
autoFocus
|
||||
autoCapitalize="words"
|
||||
returnKeyType="next"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleCreateEventType}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* URL */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-medium text-[#374151]">URL</Text>
|
||||
<View className="flex-row items-center rounded-md border border-[#D1D5DB] bg-white focus-within:border-black focus-within:ring-2 focus-within:ring-black">
|
||||
<Text className="px-3 text-base text-[#6B7280]">https://cal.com/{username}/</Text>
|
||||
<TextInput
|
||||
className="flex-1 py-2.5 pr-3 text-base text-[#111827]"
|
||||
placeholder="quick-chat"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={newEventSlug}
|
||||
onChangeText={(text) => {
|
||||
setIsSlugManuallyEdited(true);
|
||||
setNewEventSlug(slugify(text, true));
|
||||
}}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<View className="mb-4">
|
||||
<Text className="mb-2 text-sm font-medium text-[#374151]">Description</Text>
|
||||
<TextInput
|
||||
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827] focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="A quick video meeting."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={newEventDescription}
|
||||
onChangeText={setNewEventDescription}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Duration */}
|
||||
<View className="mb-1">
|
||||
<Text className="mb-2 text-sm font-medium text-[#374151]">Duration</Text>
|
||||
<View className="flex-row items-center">
|
||||
<TextInput
|
||||
className="w-20 rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-center text-base text-[#111827] focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="15"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={newEventDuration}
|
||||
onChangeText={setNewEventDuration}
|
||||
keyboardType="number-pad"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleCreateEventType}
|
||||
/>
|
||||
<Text className="ml-3 text-base text-[#6B7280]">minutes</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function MoreLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
// Hide native header on Android/Web since we use custom Header component
|
||||
// iOS uses Stack.Header in the component itself
|
||||
headerShown: Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Alert, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
|
||||
interface MoreMenuItem {
|
||||
name: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
isExternal?: boolean;
|
||||
href?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export default function More() {
|
||||
const router = useRouter();
|
||||
const { logout } = useAuth();
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false);
|
||||
|
||||
const performLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch (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.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: performLogout,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const menuItems: MoreMenuItem[] = [
|
||||
{
|
||||
name: "Apps",
|
||||
icon: "grid-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openInAppBrowser("https://app.cal.com/apps", "Apps page"),
|
||||
},
|
||||
{
|
||||
name: "Routing",
|
||||
icon: "git-branch-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openInAppBrowser("https://app.cal.com/routing", "Routing page"),
|
||||
},
|
||||
{
|
||||
name: "Workflows",
|
||||
icon: "flash-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openInAppBrowser("https://app.cal.com/workflows", "Workflows page"),
|
||||
},
|
||||
{
|
||||
name: "Insights",
|
||||
icon: "bar-chart-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openInAppBrowser("https://app.cal.com/insights", "Insights page"),
|
||||
},
|
||||
{
|
||||
name: "Support",
|
||||
icon: "help-circle-outline",
|
||||
isExternal: true,
|
||||
onPress: () => openInAppBrowser("https://go.cal.com/support", "Support"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* iOS Native Header with Glass UI */}
|
||||
<Stack.Header
|
||||
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
|
||||
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
|
||||
>
|
||||
<Stack.Header.Title large>More</Stack.Header.Title>
|
||||
<Stack.Header.Right>
|
||||
{/* Profile Button */}
|
||||
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
|
||||
<Stack.Header.Icon sf="person.circle.fill" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={{ backgroundColor: "#f8f9fa" }}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
{menuItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.name}
|
||||
onPress={item.onPress}
|
||||
className={`flex-row items-center justify-between bg-white px-5 py-5 active:bg-[#F8F9FA] ${
|
||||
index < menuItems.length - 1 ? "border-b border-[#E5E5EA]" : ""
|
||||
}`}
|
||||
>
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<Ionicons name={item.icon} size={20} color="#333" />
|
||||
<Text className="ml-3 text-base font-semibold text-[#333]">{item.name}</Text>
|
||||
</View>
|
||||
{item.isExternal ? (
|
||||
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Sign Out Button */}
|
||||
<View className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<TouchableOpacity
|
||||
onPress={handleSignOut}
|
||||
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50"
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={20} color="#800000" />
|
||||
<Text className="ml-2 text-base font-medium text-[#800000]">Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Footer Note */}
|
||||
<Text className="mt-6 px-1 text-center text-xs text-gray-400">
|
||||
The companion app is an extension of the web application.{"\n"}
|
||||
For advanced features, visit{" "}
|
||||
<Text
|
||||
className="text-gray-800"
|
||||
onPress={() => openInAppBrowser("https://app.cal.com", "Cal.com")}
|
||||
>
|
||||
app.cal.com
|
||||
</Text>
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
{/* Logout Confirmation Modal - kept for consistency */}
|
||||
<LogoutConfirmModal
|
||||
visible={showLogoutModal}
|
||||
onConfirm={() => {
|
||||
setShowLogoutModal(false);
|
||||
performLogout();
|
||||
}}
|
||||
onCancel={() => setShowLogoutModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function More() {
|
||||
// Use modal for web/extension since Alert.alert doesn't work
|
||||
setShowLogoutModal(true);
|
||||
} else {
|
||||
// Use native Alert for iOS/Android
|
||||
// Use native Alert for Android
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
@@ -51,13 +51,6 @@ export default function More() {
|
||||
};
|
||||
|
||||
const menuItems: MoreMenuItem[] = [
|
||||
{
|
||||
name: "Profile",
|
||||
icon: "person-outline",
|
||||
isExternal: true,
|
||||
onPress: () =>
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"),
|
||||
},
|
||||
{
|
||||
name: "Apps",
|
||||
icon: "grid-outline",
|
||||
@@ -10,7 +10,11 @@ export default function TabLayout() {
|
||||
|
||||
return (
|
||||
<NativeTabs
|
||||
disableTransparentOnScrollEdge={true} // Used to prevent transparent background on iOS 18 and older
|
||||
tintColor="#000000" // Base tint color (black for selected)
|
||||
labelStyle={{
|
||||
default: { color: "#8E8E93", fontSize: 8.5 }, // Gray text when unselected
|
||||
selected: { color: "#000000", fontSize: 10 }, // Black text when selected
|
||||
}}
|
||||
>
|
||||
<NativeTabs.Trigger name="(event-types)">
|
||||
<NativeTabs.Trigger.Icon
|
||||
@@ -30,15 +34,15 @@ export default function TabLayout() {
|
||||
|
||||
<NativeTabs.Trigger name="(availability)">
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf="clock"
|
||||
sf={{ default: "clock", selected: "clock.fill" }}
|
||||
src={<VectorIcon family={MaterialCommunityIcons} name="clock" />}
|
||||
/>
|
||||
<NativeTabs.Trigger.Label>Availability</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="more">
|
||||
<NativeTabs.Trigger name="(more)">
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf="ellipsis"
|
||||
sf={{ default: "ellipsis", selected: "ellipsis" }}
|
||||
src={<VectorIcon family={MaterialCommunityIcons} name="dots-horizontal" />}
|
||||
/>
|
||||
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
|
||||
@@ -96,7 +100,7 @@ function WebTabs() {
|
||||
/>
|
||||
|
||||
<Tabs.Screen
|
||||
name="more"
|
||||
name="(more)"
|
||||
options={{
|
||||
title: "More",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform, StatusBar, View } from "react-native";
|
||||
import LoginScreenComponent from "@/components/LoginScreen";
|
||||
@@ -12,6 +13,33 @@ function RootLayoutContent() {
|
||||
const content = isAuthenticated ? (
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="profile-sheet"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: Platform.OS === "ios" ? "You" : "Profile",
|
||||
presentation:
|
||||
Platform.OS === "ios"
|
||||
? isLiquidGlassAvailable()
|
||||
? "formSheet"
|
||||
: "modal"
|
||||
: "containedModal",
|
||||
// iOS-specific sheet options (ignored on Android)
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.6, 0.9] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#FFFFFF",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#FFFFFF",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<LoginScreenComponent />
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
mapItemToApiLocation,
|
||||
validateLocationItem,
|
||||
} from "@/utils/locationHelpers";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
// Type definitions for extended EventType fields not in the base type
|
||||
interface EventTypeExtended {
|
||||
@@ -370,7 +371,7 @@ export default function EventTypeDetail() {
|
||||
}
|
||||
setScheduleDetailsLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch schedule details:", error);
|
||||
safeLogError("Failed to fetch schedule details:", error);
|
||||
setSelectedScheduleDetails(null);
|
||||
setScheduleDetailsLoading(false);
|
||||
}
|
||||
@@ -390,7 +391,7 @@ export default function EventTypeDetail() {
|
||||
}
|
||||
setSchedulesLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch schedules:", error);
|
||||
safeLogError("Failed to fetch schedules:", error);
|
||||
setSchedulesLoading(false);
|
||||
}
|
||||
}, [fetchScheduleDetails]);
|
||||
@@ -402,7 +403,7 @@ export default function EventTypeDetail() {
|
||||
setConferencingOptions(options);
|
||||
setConferencingLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch conferencing options:", error);
|
||||
safeLogError("Failed to fetch conferencing options:", error);
|
||||
setConferencingLoading(false);
|
||||
}
|
||||
}, []);
|
||||
@@ -742,7 +743,7 @@ export default function EventTypeDetail() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event type data:", error);
|
||||
safeLogError("Failed to fetch event type data:", error);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
@@ -767,7 +768,7 @@ export default function EventTypeDetail() {
|
||||
const userUsername = await CalComAPIService.getUsername();
|
||||
setUsername(userUsername);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch username:", error);
|
||||
safeLogError("Failed to fetch username:", error);
|
||||
}
|
||||
};
|
||||
fetchUsername();
|
||||
@@ -851,7 +852,7 @@ export default function EventTypeDetail() {
|
||||
const link = await CalComAPIService.buildEventTypeLink(eventTypeSlug);
|
||||
await openInAppBrowser(link, "event type preview");
|
||||
} catch (error) {
|
||||
console.error("Failed to generate preview link:", error);
|
||||
safeLogError("Failed to generate preview link:", error);
|
||||
showErrorAlert("Error", "Failed to generate preview link. Please try again.");
|
||||
}
|
||||
};
|
||||
@@ -864,7 +865,7 @@ export default function EventTypeDetail() {
|
||||
await Clipboard.setStringAsync(link);
|
||||
Alert.alert("Success", "Link copied!");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy link:", error);
|
||||
safeLogError("Failed to copy link:", error);
|
||||
showErrorAlert("Error", "Failed to copy link. Please try again.");
|
||||
}
|
||||
};
|
||||
@@ -893,9 +894,8 @@ export default function EventTypeDetail() {
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete event type:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
showErrorAlert("Error", `Failed to delete event type: ${errorMessage}`);
|
||||
safeLogError("Failed to delete event type:", error);
|
||||
showErrorAlert("Error", "Failed to delete event type. Please try again.");
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -1060,7 +1060,7 @@ export default function EventTypeDetail() {
|
||||
setSaving(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save event type:", error);
|
||||
safeLogError("Failed to save event type:", error);
|
||||
const action = isCreateMode ? "create" : "update";
|
||||
showErrorAlert("Error", `Failed to ${action} event type. Please try again.`);
|
||||
setSaving(false);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { ActivityIndicator, Platform, Text, View } from "react-native";
|
||||
import { useAuth } from "@/contexts";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
export default function OAuthCallback() {
|
||||
const router = useRouter();
|
||||
@@ -26,7 +27,7 @@ export default function OAuthCallback() {
|
||||
// Handle OAuth error response
|
||||
if (error) {
|
||||
const errorMessage = errorDescription || error || "OAuth authorization failed";
|
||||
console.error("OAuth error:", error, errorDescription);
|
||||
safeLogError("OAuth error occurred", { error, errorDescription });
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
if (window.opener) {
|
||||
@@ -75,7 +76,7 @@ export default function OAuthCallback() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("No authorization code or state in OAuth callback");
|
||||
safeLogError("No authorization code or state in OAuth callback", { code, state });
|
||||
|
||||
// Handle error case
|
||||
if (typeof window !== "undefined") {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { ActivityIndicator, Alert, Image, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useUserProfile } from "@/hooks";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import { getAvatarUrl } from "@/utils/getAvatarUrl";
|
||||
|
||||
interface ProfileMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presentation style for the profile sheet
|
||||
* - Uses formSheet on iPhone with liquid glass support
|
||||
* - Uses modal on iPad or older iOS devices
|
||||
*/
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function ProfileSheet() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { data: userProfile, isLoading } = useUserProfile();
|
||||
|
||||
const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null;
|
||||
|
||||
const menuItems: ProfileMenuItem[] = [
|
||||
{
|
||||
id: "profile",
|
||||
label: "My Profile",
|
||||
icon: "person-outline",
|
||||
onPress: () =>
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "My Settings",
|
||||
icon: "settings-outline",
|
||||
onPress: () =>
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/general", "Settings page"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "outOfOffice",
|
||||
label: "Out of Office",
|
||||
icon: "moon-outline",
|
||||
onPress: () =>
|
||||
openInAppBrowser(
|
||||
"https://app.cal.com/settings/my-account/out-of-office",
|
||||
"Out of Office page"
|
||||
),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "publicPage",
|
||||
label: "View public page",
|
||||
icon: "globe-outline",
|
||||
onPress: () => {
|
||||
if (publicPageUrl) openInAppBrowser(publicPageUrl, "Public page");
|
||||
},
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "copyPublicPage",
|
||||
label: "Copy public page link",
|
||||
icon: "copy-outline",
|
||||
onPress: async () => {
|
||||
if (publicPageUrl) {
|
||||
await Clipboard.setStringAsync(publicPageUrl);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert("Copied!", "Public page link copied to clipboard");
|
||||
}
|
||||
},
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
id: "roadmap",
|
||||
label: "Roadmap",
|
||||
icon: "map-outline",
|
||||
onPress: () => openInAppBrowser("https://cal.com/roadmap", "Roadmap page"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help",
|
||||
icon: "help-circle-outline",
|
||||
onPress: () => openInAppBrowser("https://cal.com/help", "Help page"),
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleClose = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerLargeTitle: false,
|
||||
title: "You",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 0.9],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? "transparent" : "#FFFFFF",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
headerBlurEffect: useGlassEffect ? undefined : "light",
|
||||
headerLeft: () => null,
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={handleClose} style={{ padding: 8 }}>
|
||||
<Ionicons name="close" size={24} color="#000" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: useGlassEffect ? "transparent" : "#FFFFFF",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{/* Profile Header */}
|
||||
<View className="mt-20 border-b border-gray-200 px-6">
|
||||
{isLoading ? (
|
||||
<View className="items-center py-8">
|
||||
<ActivityIndicator size="large" color="#000" />
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center">
|
||||
{/* Avatar */}
|
||||
{userProfile?.avatarUrl ? (
|
||||
<Image
|
||||
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
|
||||
style={{ width: 64, height: 64, borderRadius: 32 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
className="items-center justify-center rounded-full bg-gray-200"
|
||||
style={{ width: 64, height: 64 }}
|
||||
>
|
||||
<Text className="text-2xl font-semibold text-gray-600">
|
||||
{userProfile?.name?.charAt(0).toUpperCase() ||
|
||||
userProfile?.email?.charAt(0).toUpperCase() ||
|
||||
"?"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Name and Status */}
|
||||
<View className="ml-4 flex-1">
|
||||
<Text className="text-xl font-semibold text-gray-900">
|
||||
{userProfile?.name || "User"}
|
||||
</Text>
|
||||
{userProfile?.email ? (
|
||||
<Text className="mt-1 text-sm text-gray-500">{userProfile.email}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Menu Items */}
|
||||
<View className="px-2 py-4">
|
||||
{menuItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
className={`flex-row items-center justify-between rounded-xl px-4 py-4 active:bg-gray-100 ${
|
||||
index < menuItems.length - 1 ? "mb-1" : ""
|
||||
}`}
|
||||
onPress={item.onPress}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name={item.icon} size={22} color="#374151" />
|
||||
<Text className="ml-4 text-base text-gray-900">{item.label}</Text>
|
||||
</View>
|
||||
{item.external ? (
|
||||
<Ionicons name="open-outline" size={18} color="#9CA3AF" />
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useUserProfile } from "@/hooks";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import { getAvatarUrl } from "@/utils/getAvatarUrl";
|
||||
|
||||
interface ProfileMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export default function ProfileSheet() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { data: userProfile, isLoading } = useUserProfile();
|
||||
|
||||
const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null;
|
||||
|
||||
const menuItems: ProfileMenuItem[] = [
|
||||
{
|
||||
id: "profile",
|
||||
label: "My Profile",
|
||||
icon: "person-outline",
|
||||
onPress: () =>
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "My Settings",
|
||||
icon: "settings-outline",
|
||||
onPress: () =>
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/general", "Settings page"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "outOfOffice",
|
||||
label: "Out of Office",
|
||||
icon: "moon-outline",
|
||||
onPress: () =>
|
||||
openInAppBrowser(
|
||||
"https://app.cal.com/settings/my-account/out-of-office",
|
||||
"Out of Office page"
|
||||
),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "publicPage",
|
||||
label: "View public page",
|
||||
icon: "globe-outline",
|
||||
onPress: () => {
|
||||
if (publicPageUrl) openInAppBrowser(publicPageUrl, "Public page");
|
||||
},
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "copyPublicPage",
|
||||
label: "Copy public page link",
|
||||
icon: "copy-outline",
|
||||
onPress: async () => {
|
||||
if (publicPageUrl) {
|
||||
await Clipboard.setStringAsync(publicPageUrl);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert("Copied!", "Public page link copied to clipboard");
|
||||
}
|
||||
},
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
id: "roadmap",
|
||||
label: "Roadmap",
|
||||
icon: "map-outline",
|
||||
onPress: () => openInAppBrowser("https://cal.com/roadmap", "Roadmap page"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help",
|
||||
icon: "help-circle-outline",
|
||||
onPress: () => openInAppBrowser("https://cal.com/help", "Help page"),
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleClose = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Profile",
|
||||
presentation: "modal",
|
||||
contentStyle: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
headerLeft: () => null,
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={handleClose} style={{ padding: 8 }}>
|
||||
<Ionicons name="close" size={24} color="#000" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: "#FFFFFF" }}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 20 }}
|
||||
>
|
||||
{/* Profile Header */}
|
||||
<View className="border-b border-gray-200 px-6 py-6">
|
||||
{isLoading ? (
|
||||
<View className="items-center py-8">
|
||||
<ActivityIndicator size="large" color="#000" />
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center">
|
||||
{/* Avatar */}
|
||||
{userProfile?.avatarUrl ? (
|
||||
<Image
|
||||
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
|
||||
style={{ width: 64, height: 64, borderRadius: 32 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
className="items-center justify-center rounded-full bg-gray-200"
|
||||
style={{ width: 64, height: 64 }}
|
||||
>
|
||||
<Text className="text-2xl font-semibold text-gray-600">
|
||||
{userProfile?.name?.charAt(0).toUpperCase() ||
|
||||
userProfile?.email?.charAt(0).toUpperCase() ||
|
||||
"?"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Name and Email */}
|
||||
<View className="ml-4 flex-1">
|
||||
<Text className="text-xl font-semibold text-gray-900">
|
||||
{userProfile?.name || "User"}
|
||||
</Text>
|
||||
{userProfile?.email ? (
|
||||
<Text className="mt-1 text-sm text-gray-500">{userProfile.email}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Menu Items */}
|
||||
<View className="px-2 py-4">
|
||||
{menuItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
className={`flex-row items-center justify-between rounded-xl px-4 py-4 active:bg-gray-100 ${
|
||||
index < menuItems.length - 1 ? "mb-1" : ""
|
||||
}`}
|
||||
onPress={item.onPress}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name={item.icon} size={22} color="#374151" />
|
||||
<Text className="ml-4 text-base text-gray-900">{item.label}</Text>
|
||||
</View>
|
||||
{item.external ? (
|
||||
<Ionicons name="open-outline" size={18} color="#9CA3AF" />
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+35
-290
@@ -1,28 +1,17 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionSheetIOS,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Platform,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ActivityIndicator, Image, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CalComAPIService, type UserProfile } from "@/services/calcom";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import { getAvatarUrl } from "@/utils/getAvatarUrl";
|
||||
import { CalComLogo } from "./CalComLogo";
|
||||
import { FullScreenModal } from "./FullScreenModal";
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
|
||||
const fetchUserProfile = useCallback(async () => {
|
||||
try {
|
||||
@@ -44,290 +33,46 @@ export function Header() {
|
||||
fetchUserProfile();
|
||||
}, [fetchUserProfile]);
|
||||
|
||||
// Build public page URL
|
||||
const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null;
|
||||
|
||||
const handleViewPublicPage = () => {
|
||||
if (publicPageUrl) {
|
||||
openInAppBrowser(publicPageUrl, "Public page");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyPublicPageLink = async () => {
|
||||
if (!publicPageUrl) return;
|
||||
try {
|
||||
await Clipboard.setStringAsync(publicPageUrl);
|
||||
Alert.alert("Link Copied!", "Your public page link has been copied to clipboard.");
|
||||
} catch (err) {
|
||||
console.error("Failed to copy public page link");
|
||||
if (__DEV__) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
console.debug("[Header] copyPublicPageLink failed", { message, stack });
|
||||
}
|
||||
Alert.alert("Error", "Failed to copy link. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfile = () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const options = [
|
||||
"Cancel",
|
||||
"My Profile",
|
||||
"My Settings",
|
||||
"Out of Office",
|
||||
"View public page",
|
||||
"Copy public page link",
|
||||
"Help",
|
||||
];
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex: 0,
|
||||
title: userProfile?.name || "Profile Menu",
|
||||
},
|
||||
(buttonIndex) => {
|
||||
switch (buttonIndex) {
|
||||
case 1: // My Profile
|
||||
handleMenuOption("profile");
|
||||
break;
|
||||
case 2: // My Settings
|
||||
handleMenuOption("settings");
|
||||
break;
|
||||
case 3: // Out of Office
|
||||
handleMenuOption("outOfOffice");
|
||||
break;
|
||||
case 4: // View public page
|
||||
handleViewPublicPage();
|
||||
break;
|
||||
case 5: // Copy public page link
|
||||
handleCopyPublicPageLink();
|
||||
break;
|
||||
case 6: // Help
|
||||
handleMenuOption("help");
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setShowProfileModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuOption = (option: string) => {
|
||||
if (Platform.OS !== "ios") {
|
||||
setShowProfileModal(false);
|
||||
}
|
||||
switch (option) {
|
||||
case "profile":
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page");
|
||||
break;
|
||||
case "settings":
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account", "Settings page");
|
||||
break;
|
||||
case "outOfOffice":
|
||||
openInAppBrowser(
|
||||
"https://app.cal.com/settings/my-account/out-of-office",
|
||||
"Out of Office page"
|
||||
);
|
||||
break;
|
||||
case "roadmap":
|
||||
openInAppBrowser("https://cal.com/roadmap", "Roadmap");
|
||||
break;
|
||||
case "help":
|
||||
openInAppBrowser("https://cal.com/help", "Help page");
|
||||
break;
|
||||
}
|
||||
// Navigate to profile sheet on all platforms (Android, Web, Extension)
|
||||
router.push("/profile-sheet");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5EA] bg-white px-2 md:px-4"
|
||||
style={{ paddingTop: insets.top + 4, paddingBottom: 4 }}
|
||||
>
|
||||
{/* Left: Cal.com Logo */}
|
||||
<View className="ms-1">
|
||||
<CalComLogo width={101} height={22} color="#333" />
|
||||
</View>
|
||||
|
||||
{/* Right: Icons */}
|
||||
<View
|
||||
className="flex-row items-center gap-4"
|
||||
style={Platform.OS === "web" ? { marginRight: 8 } : {}}
|
||||
>
|
||||
{/* Profile Picture */}
|
||||
<TouchableOpacity onPress={handleProfile} className="p-1">
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#666" />
|
||||
) : userProfile?.avatarUrl ? (
|
||||
<Image
|
||||
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
|
||||
className="h-8 w-8 rounded-full"
|
||||
style={{ width: 32, height: 32, borderRadius: 16 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
className="items-center justify-center rounded-full bg-[#E5E5EA]"
|
||||
style={{ width: 32, height: 32 }}
|
||||
>
|
||||
<Ionicons name="person-outline" size={20} color="#666" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5EA] bg-white px-2 md:px-4"
|
||||
style={{ paddingTop: insets.top + 4, paddingBottom: 4 }}
|
||||
>
|
||||
{/* Left: Cal.com Logo */}
|
||||
<View className="ms-1">
|
||||
<CalComLogo width={101} height={22} color="#333" />
|
||||
</View>
|
||||
|
||||
{/* Profile Menu Modal */}
|
||||
<FullScreenModal
|
||||
visible={showProfileModal}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => setShowProfileModal(false)}
|
||||
{/* Right: Icons */}
|
||||
<View
|
||||
className="flex-row items-center gap-4"
|
||||
style={Platform.OS === "web" ? { marginRight: 8 } : {}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowProfileModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with profile info */}
|
||||
<View className="border-b border-gray-200 p-6">
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
|
||||
{userProfile?.avatarUrl ? (
|
||||
<Image
|
||||
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
|
||||
className="h-12 w-12 rounded-full"
|
||||
style={{ width: 48, height: 48, borderRadius: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-lg font-semibold text-white">
|
||||
{userProfile?.name
|
||||
? userProfile.name.charAt(0).toUpperCase()
|
||||
: userProfile?.email?.charAt(0).toUpperCase() || "?"}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-semibold text-gray-900">
|
||||
{userProfile?.name || "User"}
|
||||
</Text>
|
||||
{userProfile?.email ? (
|
||||
<Text className="text-sm text-gray-600">{userProfile.email}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{/* Profile Picture */}
|
||||
<TouchableOpacity onPress={handleProfile} className="p-1">
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#666" />
|
||||
) : userProfile?.avatarUrl ? (
|
||||
<Image
|
||||
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
|
||||
className="h-8 w-8 rounded-full"
|
||||
style={{ width: 32, height: 32, borderRadius: 16 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
className="items-center justify-center rounded-full bg-[#E5E5EA]"
|
||||
style={{ width: 32, height: 32 }}
|
||||
>
|
||||
<Ionicons name="person-outline" size={20} color="#666" />
|
||||
</View>
|
||||
|
||||
{/* Menu Items */}
|
||||
<View className="p-2">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => handleMenuOption("profile")}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="person-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">My Profile</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => handleMenuOption("settings")}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="settings-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">My Settings</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => handleMenuOption("outOfOffice")}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="moon-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Out of Office</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
{/* View public page */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => {
|
||||
setShowProfileModal(false);
|
||||
handleViewPublicPage();
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="globe-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">View public page</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Copy public page link */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => {
|
||||
setShowProfileModal(false);
|
||||
handleCopyPublicPageLink();
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="copy-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Copy public page link</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => handleMenuOption("roadmap")}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="map-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Roadmap</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
|
||||
onPress={() => handleMenuOption("help")}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="help-circle-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-3 text-base text-gray-900">Help</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={16} color="#6B7280" />
|
||||
</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={() => setShowProfileModal(false)}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
</>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function LoginScreen() {
|
||||
disabled={loading}
|
||||
className="flex-row items-center justify-center rounded-2xl py-[18px]"
|
||||
style={[
|
||||
{ backgroundColor: loading ? "#9CA3AF" : "#111827" },
|
||||
{ backgroundColor: loading ? "#9CA3AF" : "#000000" },
|
||||
Platform.select({
|
||||
web: {
|
||||
boxShadow: loading ? "none" : "0 4px 12px rgba(0, 0, 0, 0.2)",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
export interface AddGuestsScreenProps {
|
||||
booking: Booking | null;
|
||||
@@ -93,7 +94,8 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
Alert.alert("Success", "Guests added successfully", [{ text: "OK", onPress: onSuccess }]);
|
||||
setIsSaving(false);
|
||||
} catch (error) {
|
||||
Alert.alert("Error", error instanceof Error ? error.message : "Failed to add guests");
|
||||
safeLogError("[AddGuestsScreen] Failed to add guests:", error);
|
||||
Alert.alert("Error", "Failed to add guests. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, guests, onSuccess]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
// Location types configuration
|
||||
export const LOCATION_TYPES = {
|
||||
@@ -161,8 +162,8 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
]);
|
||||
setIsSaving(false);
|
||||
} catch (error) {
|
||||
console.error("[EditLocationScreen] Failed to update location:", error);
|
||||
Alert.alert("Error", error instanceof Error ? error.message : "Failed to update location");
|
||||
safeLogError("[EditLocationScreen] Failed to update location:", error);
|
||||
Alert.alert("Error", "Failed to update location. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedType, inputValue, onSuccess]);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
export const LOCATION_TYPES = {
|
||||
link: {
|
||||
@@ -164,8 +165,8 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
]);
|
||||
setIsSaving(false);
|
||||
} catch (error) {
|
||||
console.error("[EditLocationScreen] Failed to update location:", error);
|
||||
Alert.alert("Error", error instanceof Error ? error.message : "Failed to update location");
|
||||
safeLogError("[EditLocationScreen] Failed to update location:", error);
|
||||
Alert.alert("Error", "Failed to update location. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedType, inputValue, onSuccess]);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
|
||||
// Note: @expo/ui DateTimePicker components are not yet stable
|
||||
// Using a simple inline picker approach instead
|
||||
@@ -82,11 +83,8 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
]);
|
||||
setIsSaving(false);
|
||||
} catch (error) {
|
||||
console.error("[RescheduleScreen] Failed to reschedule:", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
error instanceof Error ? error.message : "Failed to reschedule booking"
|
||||
);
|
||||
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedDateTime, reason, onSuccess]);
|
||||
@@ -187,7 +185,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
<TouchableOpacity
|
||||
className="border-b border-gray-100 px-4 py-3"
|
||||
onPress={() => {
|
||||
console.log("[RescheduleScreen] Opening date picker");
|
||||
safeLogInfo("[RescheduleScreen] Opening date picker");
|
||||
setShowDatePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
@@ -206,7 +204,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
<TouchableOpacity
|
||||
className="border-b border-gray-100 px-4 py-3"
|
||||
onPress={() => {
|
||||
console.log("[RescheduleScreen] Opening time picker");
|
||||
safeLogInfo("[RescheduleScreen] Opening time picker");
|
||||
setShowTimePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
|
||||
@@ -12,6 +12,156 @@ const devLog = {
|
||||
error: (...args: unknown[]) => console.error("[Cal.com]", ...args),
|
||||
};
|
||||
|
||||
/**
|
||||
* Request timeout in milliseconds (30 seconds)
|
||||
*/
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Fetch with timeout to prevent hanging requests
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs: number = REQUEST_TIMEOUT_MS
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON with validation for OAuthTokens
|
||||
*/
|
||||
function safeParseOAuthTokens(jsonString: string): OAuthTokens | null {
|
||||
if (typeof jsonString !== "string" || !jsonString.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonString);
|
||||
|
||||
// Validate it's a plain object (not null, array, or primitive)
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.getPrototypeOf(parsed) !== Object.prototype
|
||||
) {
|
||||
devLog.warn("Invalid OAuth tokens structure - not a plain object");
|
||||
return null;
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// Validate required fields: accessToken and tokenType must be strings
|
||||
if (typeof obj.accessToken !== "string") {
|
||||
devLog.warn("Invalid OAuth tokens structure - missing accessToken");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof obj.tokenType !== "string") {
|
||||
devLog.warn("Invalid OAuth tokens structure - missing tokenType");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate optional fields have correct types if present
|
||||
if (obj.refreshToken !== undefined && typeof obj.refreshToken !== "string") {
|
||||
devLog.warn("Invalid OAuth tokens structure - invalid refreshToken type");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (obj.expiresAt !== undefined && typeof obj.expiresAt !== "number") {
|
||||
devLog.warn("Invalid OAuth tokens structure - invalid expiresAt type");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (obj.scope !== undefined && typeof obj.scope !== "string") {
|
||||
devLog.warn("Invalid OAuth tokens structure - invalid scope type");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Construct validated OAuthTokens object
|
||||
const tokens: OAuthTokens = {
|
||||
accessToken: obj.accessToken,
|
||||
tokenType: obj.tokenType,
|
||||
};
|
||||
|
||||
if (typeof obj.refreshToken === "string") {
|
||||
tokens.refreshToken = obj.refreshToken;
|
||||
}
|
||||
|
||||
if (typeof obj.expiresAt === "number") {
|
||||
tokens.expiresAt = obj.expiresAt;
|
||||
}
|
||||
|
||||
if (typeof obj.scope === "string") {
|
||||
tokens.scope = obj.scope;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
} catch (error) {
|
||||
devLog.warn("Failed to parse OAuth tokens:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON error response with structure validation.
|
||||
* Validates the expected error response structure before returning.
|
||||
*/
|
||||
function safeParseErrorJson(
|
||||
jsonString: string
|
||||
): { error?: { message?: string }; message?: string } | null {
|
||||
if (typeof jsonString !== "string" || !jsonString.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonString);
|
||||
|
||||
// Validate it's a plain object
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.getPrototypeOf(parsed) !== Object.prototype
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// Validate expected error structure - must have error or message property with correct types
|
||||
const hasValidErrorProp =
|
||||
obj.error === undefined ||
|
||||
(typeof obj.error === "object" &&
|
||||
obj.error !== null &&
|
||||
!Array.isArray(obj.error) &&
|
||||
((obj.error as Record<string, unknown>).message === undefined ||
|
||||
typeof (obj.error as Record<string, unknown>).message === "string"));
|
||||
|
||||
const hasValidMessageProp = obj.message === undefined || typeof obj.message === "string";
|
||||
|
||||
if (hasValidErrorProp && hasValidMessageProp) {
|
||||
return obj as { error?: { message?: string }; message?: string };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser type enum (inlined to avoid import issues in service worker)
|
||||
*/
|
||||
@@ -427,7 +577,7 @@ async function handleTokenExchange(
|
||||
|
||||
const body = new URLSearchParams(tokenRequest);
|
||||
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
const response = await fetchWithTimeout(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -520,7 +670,7 @@ async function validateTokens(tokens: OAuthTokens): Promise<boolean> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/me`, {
|
||||
const response = await fetchWithTimeout(`${API_BASE_URL}/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
@@ -547,7 +697,7 @@ async function getAuthHeader(): Promise<string> {
|
||||
if (storageAPI?.local) {
|
||||
const result = await storageAPI.local.get(["cal_oauth_tokens"]);
|
||||
const oauthTokens = result.cal_oauth_tokens
|
||||
? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens)
|
||||
? safeParseOAuthTokens(result.cal_oauth_tokens as string)
|
||||
: null;
|
||||
|
||||
if (oauthTokens?.accessToken) {
|
||||
@@ -566,7 +716,7 @@ async function getAuthHeader(): Promise<string> {
|
||||
async function fetchEventTypes(): Promise<unknown[]> {
|
||||
const authHeader = await getAuthHeader();
|
||||
|
||||
const userResponse = await fetch(`${API_BASE_URL}/me`, {
|
||||
const userResponse = await fetchWithTimeout(`${API_BASE_URL}/me`, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/json",
|
||||
@@ -588,7 +738,7 @@ async function fetchEventTypes(): Promise<unknown[]> {
|
||||
const queryString = params.toString();
|
||||
const endpoint = `${API_BASE_URL}/event-types${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
const response = await fetchWithTimeout(endpoint, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/json",
|
||||
@@ -628,7 +778,7 @@ async function checkAuthStatus(): Promise<boolean> {
|
||||
try {
|
||||
const result = await storageAPI.local.get(["cal_oauth_tokens"]);
|
||||
const oauthTokens = result.cal_oauth_tokens
|
||||
? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens)
|
||||
? safeParseOAuthTokens(result.cal_oauth_tokens as string)
|
||||
: null;
|
||||
|
||||
return Boolean(oauthTokens?.accessToken);
|
||||
@@ -644,7 +794,7 @@ async function checkAuthStatus(): Promise<boolean> {
|
||||
async function getBookingStatus(bookingUid: string): Promise<Booking> {
|
||||
const authHeader = await getAuthHeader();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/bookings/${bookingUid}`, {
|
||||
const response = await fetchWithTimeout(`${API_BASE_URL}/bookings/${bookingUid}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
@@ -657,10 +807,10 @@ async function getBookingStatus(bookingUid: string): Promise<Booking> {
|
||||
const errorBody = await response.text();
|
||||
let errorMessage = response.statusText;
|
||||
|
||||
try {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
const errorJson = safeParseErrorJson(errorBody);
|
||||
if (errorJson) {
|
||||
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
|
||||
} catch {
|
||||
} else {
|
||||
errorMessage = errorBody || response.statusText;
|
||||
}
|
||||
|
||||
@@ -691,7 +841,7 @@ async function markAttendeeNoShow(
|
||||
): Promise<Booking> {
|
||||
const authHeader = await getAuthHeader();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/bookings/${bookingUid}/mark-absent`, {
|
||||
const response = await fetchWithTimeout(`${API_BASE_URL}/bookings/${bookingUid}/mark-absent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
@@ -707,10 +857,10 @@ async function markAttendeeNoShow(
|
||||
const errorBody = await response.text();
|
||||
let errorMessage = response.statusText;
|
||||
|
||||
try {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
const errorJson = safeParseErrorJson(errorBody);
|
||||
if (errorJson) {
|
||||
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
|
||||
} catch {
|
||||
} else {
|
||||
errorMessage = errorBody || response.statusText;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
import { initGoogleCalendarIntegration } from "../lib/google-calendar";
|
||||
import { initLinkedInIntegration } from "../lib/linkedin";
|
||||
|
||||
/**
|
||||
* Development-only logging utility for content scripts.
|
||||
* In production builds, these logs are suppressed to prevent sensitive data exposure.
|
||||
*/
|
||||
const IS_DEV_MODE =
|
||||
typeof process !== "undefined" && process.env && process.env.NODE_ENV === "development";
|
||||
|
||||
const devLog = {
|
||||
log: (...args: unknown[]) => IS_DEV_MODE && console.log("[Cal.com]", ...args),
|
||||
warn: (...args: unknown[]) => IS_DEV_MODE && console.warn("[Cal.com]", ...args),
|
||||
error: (...args: unknown[]) => IS_DEV_MODE && console.error("[Cal.com]", ...args),
|
||||
};
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["<all_urls>"],
|
||||
main() {
|
||||
@@ -94,11 +107,11 @@ export default defineContentScript({
|
||||
|
||||
const validateSessionToken = (providedToken: string | undefined): boolean => {
|
||||
if (!iframeSessionValidated) {
|
||||
console.warn("Cal.com: Session not validated yet");
|
||||
devLog.warn("Session not validated yet");
|
||||
return false;
|
||||
}
|
||||
if (providedToken !== sessionToken) {
|
||||
console.warn("Cal.com: Invalid session token");
|
||||
devLog.warn("Invalid session token");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -165,7 +178,7 @@ export default defineContentScript({
|
||||
{ action: "start-extension-oauth", authUrl: authUrl },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error(
|
||||
devLog.error(
|
||||
"Failed to communicate with background script:",
|
||||
chrome.runtime.lastError.message
|
||||
);
|
||||
@@ -221,7 +234,7 @@ export default defineContentScript({
|
||||
},
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error(
|
||||
devLog.error(
|
||||
"Failed to communicate with background script:",
|
||||
chrome.runtime.lastError.message
|
||||
);
|
||||
@@ -266,7 +279,7 @@ export default defineContentScript({
|
||||
) {
|
||||
chrome.runtime.sendMessage({ action: "sync-oauth-tokens", tokens }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error(
|
||||
devLog.error(
|
||||
"Failed to communicate with background script:",
|
||||
chrome.runtime.lastError.message
|
||||
);
|
||||
@@ -295,7 +308,7 @@ export default defineContentScript({
|
||||
function handleClearTokensRequest(iframeWindow: Window | null) {
|
||||
chrome.runtime.sendMessage({ action: "clear-oauth-tokens" }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error(
|
||||
devLog.error(
|
||||
"Failed to communicate with background script:",
|
||||
chrome.runtime.lastError.message
|
||||
);
|
||||
@@ -2292,7 +2305,7 @@ export default defineContentScript({
|
||||
try {
|
||||
// Validate input
|
||||
if (!html || typeof html !== "string") {
|
||||
console.warn("Cal.com: Invalid HTML to insert");
|
||||
devLog.warn("Invalid HTML to insert");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2322,7 +2335,7 @@ export default defineContentScript({
|
||||
}
|
||||
|
||||
if (!composeBody) {
|
||||
console.warn("Cal.com: Gmail compose field not found");
|
||||
devLog.warn("Gmail compose field not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2330,7 +2343,7 @@ export default defineContentScript({
|
||||
try {
|
||||
(composeBody as HTMLElement).focus();
|
||||
} catch (focusError) {
|
||||
console.warn("Cal.com: Failed to focus compose field:", focusError);
|
||||
devLog.warn("Failed to focus compose field:", focusError);
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
@@ -2342,7 +2355,7 @@ export default defineContentScript({
|
||||
composeBody.appendChild(tempDiv);
|
||||
return true;
|
||||
} catch (appendError) {
|
||||
console.warn("Cal.com: Failed to append HTML:", appendError);
|
||||
devLog.warn("Failed to append HTML:", appendError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2372,11 +2385,11 @@ export default defineContentScript({
|
||||
|
||||
return true;
|
||||
} catch (insertError) {
|
||||
console.warn("Cal.com: Failed to insert HTML at cursor:", insertError);
|
||||
devLog.warn("Failed to insert HTML at cursor:", insertError);
|
||||
return false;
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("Cal.com: Critical error inserting HTML");
|
||||
devLog.error("Critical error inserting HTML");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2390,7 +2403,7 @@ export default defineContentScript({
|
||||
try {
|
||||
// Validate input
|
||||
if (!text || typeof text !== "string") {
|
||||
console.warn("Cal.com: Invalid text to insert");
|
||||
devLog.warn("Invalid text to insert");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2423,7 +2436,7 @@ export default defineContentScript({
|
||||
}
|
||||
|
||||
if (!composeBody) {
|
||||
console.warn("Cal.com: Gmail compose field not found (structure may have changed)");
|
||||
devLog.warn("Gmail compose field not found (structure may have changed)");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2431,7 +2444,7 @@ export default defineContentScript({
|
||||
try {
|
||||
(composeBody as HTMLElement).focus();
|
||||
} catch (focusError) {
|
||||
console.warn("Cal.com: Failed to focus compose field:", focusError);
|
||||
devLog.warn("Failed to focus compose field:", focusError);
|
||||
// Continue anyway - might still work
|
||||
}
|
||||
|
||||
@@ -2447,7 +2460,7 @@ export default defineContentScript({
|
||||
selection?.addRange(range);
|
||||
return true;
|
||||
} catch (appendError) {
|
||||
console.warn("Cal.com: Failed to append text (fallback method):", appendError);
|
||||
devLog.warn("Failed to append text (fallback method):", appendError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2465,14 +2478,11 @@ export default defineContentScript({
|
||||
composeBody.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
} catch (insertError) {
|
||||
console.warn("Cal.com: Failed to insert text at cursor:", insertError);
|
||||
devLog.warn("Failed to insert text at cursor:", insertError);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Cal.com: Critical error inserting text (Gmail structure may have changed):",
|
||||
error
|
||||
);
|
||||
devLog.error("Critical error inserting text (Gmail structure may have changed):", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2550,7 +2560,7 @@ export default defineContentScript({
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
// Silently fail - notifications are non-critical
|
||||
console.warn("Cal.com: Failed to show notification:", error);
|
||||
devLog.warn("Failed to show notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2571,12 +2581,12 @@ export default defineContentScript({
|
||||
handleGoogleChipDetected(chip as HTMLElement);
|
||||
} catch (error) {
|
||||
// Silently fail for individual chips to prevent breaking other chips
|
||||
console.warn("Cal.com: Failed to process chip:", error);
|
||||
devLog.warn("Failed to process chip:", error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail to prevent Gmail UI from breaking
|
||||
console.warn("Cal.com: Failed to detect chips:", error);
|
||||
devLog.warn("Failed to detect chips:", error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2596,16 +2606,16 @@ export default defineContentScript({
|
||||
chip.setAttribute("data-calcom-chip-processed", "true");
|
||||
handleGoogleChipDetected(chip as HTMLElement);
|
||||
} catch (error) {
|
||||
console.warn("Cal.com: Failed to process existing chip:", error);
|
||||
devLog.warn("Failed to process existing chip:", error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Cal.com: Failed to find existing chips:", error);
|
||||
devLog.warn("Failed to find existing chips:", error);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (_error) {
|
||||
// Critical failure - log but don't break Gmail
|
||||
console.error("Cal.com: Failed to initialize chip watcher");
|
||||
devLog.error("Failed to initialize chip watcher");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2614,11 +2624,11 @@ export default defineContentScript({
|
||||
*/
|
||||
function handleGoogleChipDetected(chipElement: HTMLElement) {
|
||||
try {
|
||||
console.log("Cal.com: handleGoogleChipDetected called");
|
||||
devLog.log("handleGoogleChipDetected called");
|
||||
|
||||
// Validate chip element exists and is in DOM
|
||||
if (!chipElement || !chipElement.isConnected) {
|
||||
console.warn("Cal.com: Invalid or disconnected chip element");
|
||||
devLog.warn("Invalid or disconnected chip element");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2638,8 +2648,8 @@ export default defineContentScript({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Cal.com: ✅ Google chip detected - ${parsedData.slots.length} slot${
|
||||
devLog.log(
|
||||
`✅ Google chip detected - ${parsedData.slots.length} slot${
|
||||
parsedData.slots.length > 1 ? "s" : ""
|
||||
} (${parsedData.detectedDuration}min)`
|
||||
);
|
||||
@@ -2668,7 +2678,7 @@ export default defineContentScript({
|
||||
return;
|
||||
}
|
||||
// Something changed - remove old action bar and create new one
|
||||
console.log(`Cal.com: 🔄 Chip updated - ${parsedData.detectedDuration}min`);
|
||||
devLog.log(`🔄 Chip updated - ${parsedData.detectedDuration}min`);
|
||||
try {
|
||||
existingActionBar.remove();
|
||||
} catch (_e) {
|
||||
@@ -2687,7 +2697,7 @@ export default defineContentScript({
|
||||
|
||||
// Check if schedule ID changed (this changes when time range or duration changes)
|
||||
if (currentScheduleId && newScheduleId && currentScheduleId !== newScheduleId) {
|
||||
console.log(`Cal.com: 🔄 Time range/duration changed`);
|
||||
devLog.log(`🔄 Time range/duration changed`);
|
||||
try {
|
||||
actionBar?.remove();
|
||||
// Recreate action bar with new data
|
||||
@@ -2811,15 +2821,15 @@ export default defineContentScript({
|
||||
e.stopPropagation();
|
||||
// Re-parse the chip to get fresh data (in case it changed)
|
||||
const freshParsedData = parseGoogleChip(chipElement);
|
||||
console.log(
|
||||
"Cal.com: Suggest Links clicked, fresh parsed data:",
|
||||
devLog.log(
|
||||
"Suggest Links clicked, fresh parsed data:",
|
||||
freshParsedData?.detectedDuration,
|
||||
"min"
|
||||
);
|
||||
if (freshParsedData && freshParsedData.slots.length > 0) {
|
||||
showCalcomSuggestionMenu(chipElement, freshParsedData);
|
||||
} else {
|
||||
console.log("Cal.com: Failed to parse fresh data or no slots found");
|
||||
devLog.log("Failed to parse fresh data or no slots found");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2854,8 +2864,8 @@ export default defineContentScript({
|
||||
|
||||
// Re-parse the chip to get fresh data
|
||||
const freshParsedData = parseGoogleChip(chipElement);
|
||||
console.log(
|
||||
"Cal.com: Insert Embed clicked, fresh parsed data:",
|
||||
devLog.log(
|
||||
"Insert Embed clicked, fresh parsed data:",
|
||||
freshParsedData?.detectedDuration,
|
||||
"min"
|
||||
);
|
||||
@@ -2939,7 +2949,7 @@ export default defineContentScript({
|
||||
|
||||
if (inserted) {
|
||||
showGmailNotification("Cal.com embed inserted!", "success");
|
||||
console.log("Cal.com: ✅ Email embed inserted successfully");
|
||||
devLog.log("Email embed inserted successfully");
|
||||
|
||||
// Immediately remove the Google chip and action bar
|
||||
try {
|
||||
@@ -2947,7 +2957,7 @@ export default defineContentScript({
|
||||
(actionBar as HTMLElement & { __cleanup?: () => void }).__cleanup?.();
|
||||
actionBar.remove();
|
||||
} catch (removeError) {
|
||||
console.warn("Cal.com: Failed to remove chip/action bar:", removeError);
|
||||
devLog.warn("Failed to remove chip/action bar:", removeError);
|
||||
}
|
||||
} else {
|
||||
showGmailNotification("Failed to insert embed", "error");
|
||||
@@ -2957,7 +2967,7 @@ export default defineContentScript({
|
||||
embedButton.style.opacity = "1";
|
||||
embedButton.style.cursor = "pointer";
|
||||
} catch (error) {
|
||||
console.error("Cal.com: Failed to insert embed");
|
||||
devLog.error("Failed to insert embed");
|
||||
|
||||
// Check if this is the "Extension context invalidated" error
|
||||
const isContextInvalidated =
|
||||
@@ -3119,7 +3129,7 @@ export default defineContentScript({
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
};
|
||||
} catch (_error) {
|
||||
console.error("Cal.com: Failed to create or insert action bar");
|
||||
devLog.error("Failed to create or insert action bar");
|
||||
// Clean up if something failed
|
||||
try {
|
||||
actionBar.remove();
|
||||
@@ -3129,7 +3139,7 @@ export default defineContentScript({
|
||||
}
|
||||
} catch (_error) {
|
||||
// Catch-all to prevent breaking Gmail UI
|
||||
console.error("Cal.com: Error handling Google chip");
|
||||
devLog.error("Error handling Google chip");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3153,8 +3163,8 @@ export default defineContentScript({
|
||||
timezoneOffset: string;
|
||||
}
|
||||
) {
|
||||
console.log(
|
||||
"Cal.com: Opening menu for",
|
||||
devLog.log(
|
||||
"Opening menu for",
|
||||
parsedData.detectedDuration,
|
||||
"min with",
|
||||
parsedData.slots.length,
|
||||
@@ -3164,7 +3174,7 @@ export default defineContentScript({
|
||||
// Remove existing menu if any (to support reopening with new data)
|
||||
const existingBackdrop = document.querySelector(".cal-companion-google-chip-backdrop");
|
||||
if (existingBackdrop) {
|
||||
console.log("Cal.com: Removing existing backdrop");
|
||||
devLog.log("Removing existing backdrop");
|
||||
existingBackdrop.remove();
|
||||
// Wait a tick to ensure DOM is updated before creating new menu
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -3389,7 +3399,7 @@ export default defineContentScript({
|
||||
"success"
|
||||
);
|
||||
|
||||
console.log("Cal.com: Opening create URL:", createUrl);
|
||||
devLog.log("Opening create URL:", createUrl);
|
||||
});
|
||||
|
||||
createBtn?.addEventListener("mouseenter", (e) => {
|
||||
@@ -3525,7 +3535,7 @@ export default defineContentScript({
|
||||
selectedText.textContent = `${et.title} (${et.lengthInMinutes}min)`;
|
||||
}
|
||||
optionsContainer.style.display = "none";
|
||||
console.log("Cal.com: Event type changed to:", et.slug);
|
||||
devLog.log("Event type changed to:", et.slug);
|
||||
});
|
||||
|
||||
optionsContainer.appendChild(option);
|
||||
@@ -3619,7 +3629,7 @@ export default defineContentScript({
|
||||
|
||||
// Hover effect on entire slot item
|
||||
slotItem.addEventListener("mouseenter", () => {
|
||||
console.log("Cal.com: Mouse entered slot", index);
|
||||
devLog.log("Mouse entered slot", index);
|
||||
slotItem.style.borderColor = "#000";
|
||||
slotItem.style.backgroundColor = "#f8f9fa";
|
||||
const btn = slotItem.querySelector(".insert-slot-btn") as HTMLElement;
|
||||
@@ -3641,18 +3651,18 @@ export default defineContentScript({
|
||||
// Add event listeners for insert buttons
|
||||
const insertButtons = menu.querySelectorAll(".insert-slot-btn");
|
||||
|
||||
console.log("Cal.com: Found", insertButtons.length, "insert buttons");
|
||||
devLog.log("Found", insertButtons.length, "insert buttons");
|
||||
|
||||
insertButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
console.log("Cal.com: Insert button clicked!");
|
||||
devLog.log("Insert button clicked!");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const slotIndexAttr = btn.getAttribute("data-slot-index");
|
||||
if (!slotIndexAttr) return;
|
||||
const slotIndex = parseInt(slotIndexAttr, 10);
|
||||
const slot = parsedData.slots[slotIndex];
|
||||
console.log("Cal.com: Inserting link for slot", slotIndex, slot);
|
||||
devLog.log("Inserting link for slot", slotIndex, slot);
|
||||
|
||||
// Get selected event type from the tracked index
|
||||
const selectedEventType = matchingEventTypes[selectedEventTypeIndex];
|
||||
@@ -3668,7 +3678,7 @@ export default defineContentScript({
|
||||
});
|
||||
const calcomUrl = `${baseUrl}?${params.toString()}`;
|
||||
|
||||
console.log("Cal.com: Generated URL:", calcomUrl);
|
||||
devLog.log("Generated URL:", calcomUrl);
|
||||
|
||||
// Insert link into Gmail compose (pass chipElement to target the correct compose window)
|
||||
const inserted = insertGmailText(calcomUrl, chipElement);
|
||||
@@ -3693,7 +3703,7 @@ export default defineContentScript({
|
||||
actionBar.remove();
|
||||
}
|
||||
} catch (removeError) {
|
||||
console.warn("Cal.com: Failed to remove chip/action bar:", removeError);
|
||||
devLog.warn("Failed to remove chip/action bar:", removeError);
|
||||
}
|
||||
} else {
|
||||
showGmailNotification("Failed to insert link", "error");
|
||||
@@ -3701,7 +3711,7 @@ export default defineContentScript({
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error showing Cal.com suggestion menu");
|
||||
devLog.error("Error showing suggestion menu");
|
||||
loadingDiv.remove();
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : "";
|
||||
@@ -3810,7 +3820,7 @@ export default defineContentScript({
|
||||
try {
|
||||
// Validate input
|
||||
if (!chipElement || typeof chipElement.getAttribute !== "function") {
|
||||
console.warn("Cal.com: Invalid chip element passed to parser");
|
||||
devLog.warn("Invalid chip element passed to parser");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3823,10 +3833,7 @@ export default defineContentScript({
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Cal.com: Valid chip detected - Schedule ID:",
|
||||
`${scheduleId?.slice(0, 20)}...`
|
||||
);
|
||||
devLog.log("Valid chip detected - Schedule ID:", `${scheduleId?.slice(0, 20)}...`);
|
||||
|
||||
// Parse timezone (non-critical - fallback to UTC if structure changed)
|
||||
let timezone = "UTC";
|
||||
@@ -3835,7 +3842,7 @@ export default defineContentScript({
|
||||
try {
|
||||
// First, try to get the IANA timezone from data-ad-hoc-v2-params
|
||||
const paramsAttr = chipElement.getAttribute("data-ad-hoc-v2-params");
|
||||
console.log("Cal.com: data-ad-hoc-v2-params:", paramsAttr);
|
||||
devLog.log("data-ad-hoc-v2-params:", paramsAttr);
|
||||
|
||||
if (paramsAttr) {
|
||||
// The timezone is at the end of the params string, like: "Asia/Kolkata"
|
||||
@@ -3854,12 +3861,12 @@ export default defineContentScript({
|
||||
|
||||
if (tzMatch?.[1]) {
|
||||
timezone = tzMatch[1]; // e.g., "Asia/Kolkata"
|
||||
console.log("Cal.com: ✅ Parsed IANA timezone from data attribute:", timezone);
|
||||
devLog.log("Parsed IANA timezone from data attribute:", timezone);
|
||||
} else {
|
||||
console.warn("Cal.com: ⚠️ Failed to extract timezone from params attribute");
|
||||
devLog.warn("Failed to extract timezone from params attribute");
|
||||
}
|
||||
} else {
|
||||
console.warn("Cal.com: ⚠️ No data-ad-hoc-v2-params attribute found");
|
||||
devLog.warn("No data-ad-hoc-v2-params attribute found");
|
||||
}
|
||||
|
||||
// Also get the display timezone and offset from the UI text
|
||||
@@ -3868,11 +3875,11 @@ export default defineContentScript({
|
||||
|
||||
if (timezoneMatch) {
|
||||
timezoneOffset = timezoneMatch[3]?.trim() || "GMT+00:00";
|
||||
console.log("Cal.com: Parsed timezone offset from UI:", timezoneOffset);
|
||||
devLog.log("Parsed timezone offset from UI:", timezoneOffset);
|
||||
}
|
||||
} catch (tzError) {
|
||||
// Non-critical error - continue with default timezone
|
||||
console.warn("Cal.com: Failed to parse timezone, using UTC:", tzError);
|
||||
devLog.warn("Failed to parse timezone, using UTC:", tzError);
|
||||
}
|
||||
|
||||
// Find all time slot links - critical for functionality
|
||||
@@ -3880,7 +3887,7 @@ export default defineContentScript({
|
||||
try {
|
||||
slotLinks = Array.from(chipElement.querySelectorAll("a[href*='slotStartTime']"));
|
||||
} catch (error) {
|
||||
console.warn("Cal.com: Failed to find slot links:", error);
|
||||
devLog.warn("Failed to find slot links:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3955,7 +3962,7 @@ export default defineContentScript({
|
||||
});
|
||||
} catch (slotError) {
|
||||
// Skip individual slot if it fails - don't break entire parsing
|
||||
console.warn("Cal.com: Failed to parse individual slot:", slotError);
|
||||
devLog.warn("Failed to parse individual slot:", slotError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3964,8 +3971,8 @@ export default defineContentScript({
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Cal.com: ✅ Parsed chip - ${slots.length} slots, ${detectedDuration}min, timezone: ${timezone}`
|
||||
devLog.log(
|
||||
`Parsed chip - ${slots.length} slots, ${detectedDuration}min, timezone: ${timezone}`
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -3977,10 +3984,7 @@ export default defineContentScript({
|
||||
};
|
||||
} catch (error) {
|
||||
// Critical error in parsing - fail gracefully without breaking Gmail
|
||||
console.warn(
|
||||
"Cal.com: Failed to parse Google chip (Gmail structure may have changed):",
|
||||
error
|
||||
);
|
||||
devLog.warn("Failed to parse Google chip (Gmail structure may have changed):", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3996,9 +4000,9 @@ export default defineContentScript({
|
||||
if (window.location.hostname === "mail.google.com") {
|
||||
try {
|
||||
initGmailIntegration();
|
||||
console.log("Cal.com: Gmail integration initialized successfully");
|
||||
devLog.log("Gmail integration initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Cal.com: Failed to initialize Gmail integration", error);
|
||||
devLog.error("Failed to initialize Gmail integration", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4006,9 +4010,9 @@ export default defineContentScript({
|
||||
if (window.location.hostname === "www.linkedin.com") {
|
||||
try {
|
||||
initLinkedInIntegration();
|
||||
console.log("Cal.com: LinkedIn integration initialized successfully");
|
||||
devLog.log("LinkedIn integration initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Cal.com: Failed to initialize LinkedIn integration", error);
|
||||
devLog.error("Failed to initialize LinkedIn integration", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4016,9 +4020,9 @@ export default defineContentScript({
|
||||
if (window.location.hostname === "calendar.google.com") {
|
||||
try {
|
||||
initGoogleCalendarIntegration();
|
||||
console.log("Cal.com: Google Calendar integration initialized successfully");
|
||||
devLog.log("Google Calendar integration initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Cal.com: Failed to initialize Google Calendar integration", error);
|
||||
devLog.error("Failed to initialize Google Calendar integration", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+127
-29
@@ -1,3 +1,6 @@
|
||||
import { fetchWithTimeout } from "@/utils/network";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
|
||||
import type {
|
||||
AddGuestInput,
|
||||
AddGuestsResponse,
|
||||
@@ -30,6 +33,88 @@ import type {
|
||||
|
||||
const API_BASE_URL = "https://api.cal.com/v2";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Safely parse JSON response with structure validation.
|
||||
* Validates that the input is a non-null object before returning.
|
||||
* This prevents prototype pollution and ensures type safety.
|
||||
*/
|
||||
function safeParseJson(jsonString: string): Record<string, unknown> | null {
|
||||
if (typeof jsonString !== "string" || !jsonString.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonString);
|
||||
|
||||
// Validate it's a plain object (not null, array, or primitive)
|
||||
if (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
Object.getPrototypeOf(parsed) === Object.prototype
|
||||
) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Also accept arrays as valid JSON responses
|
||||
if (Array.isArray(parsed)) {
|
||||
return { data: parsed } as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON error response with structure validation.
|
||||
* Validates the expected error response structure before returning.
|
||||
*/
|
||||
function safeParseErrorJson(
|
||||
jsonString: string
|
||||
): { error?: { message?: string }; message?: string } | null {
|
||||
if (typeof jsonString !== "string" || !jsonString.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonString);
|
||||
|
||||
// Validate it's a plain object
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.getPrototypeOf(parsed) !== Object.prototype
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// Validate expected error structure - must have error or message property with correct types
|
||||
const hasValidErrorProp =
|
||||
obj.error === undefined ||
|
||||
(typeof obj.error === "object" &&
|
||||
obj.error !== null &&
|
||||
((obj.error as Record<string, unknown>).message === undefined ||
|
||||
typeof (obj.error as Record<string, unknown>).message === "string"));
|
||||
|
||||
const hasValidMessageProp = obj.message === undefined || typeof obj.message === "string";
|
||||
|
||||
if (hasValidErrorProp && hasValidMessageProp) {
|
||||
return obj as { error?: { message?: string }; message?: string };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication configuration
|
||||
interface AuthConfig {
|
||||
accessToken?: string;
|
||||
@@ -235,20 +320,29 @@ async function testRawBookingsAPI(): Promise<void> {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/bookings?status=upcoming&status=unconfirmed&limit=50`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: getAuthHeader(),
|
||||
"Content-Type": "application/json",
|
||||
"cal-api-version": "2024-08-13",
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Authorization: getAuthHeader(),
|
||||
"Content-Type": "application/json",
|
||||
"cal-api-version": "2024-08-13",
|
||||
},
|
||||
},
|
||||
});
|
||||
REQUEST_TIMEOUT_MS
|
||||
);
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
try {
|
||||
const _responseJson = JSON.parse(responseText);
|
||||
} catch (_parseError) {}
|
||||
} catch (_error) {}
|
||||
if (responseText?.trim()) {
|
||||
const _responseJson = safeParseJson(responseText);
|
||||
if (!_responseJson) {
|
||||
safeLogError("[CalComAPIService] Failed to parse bookings response", { responseText });
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
safeLogError("[CalComAPIService] testRawBookingsAPI failed", { error: _error });
|
||||
}
|
||||
}
|
||||
|
||||
async function makeRequest<T>(
|
||||
@@ -259,15 +353,19 @@ async function makeRequest<T>(
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: getAuthHeader(),
|
||||
"Content-Type": "application/json",
|
||||
"cal-api-version": apiVersion,
|
||||
...options.headers,
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: getAuthHeader(),
|
||||
"Content-Type": "application/json",
|
||||
"cal-api-version": apiVersion,
|
||||
...options.headers,
|
||||
},
|
||||
},
|
||||
});
|
||||
REQUEST_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
@@ -275,10 +373,10 @@ async function makeRequest<T>(
|
||||
// Parse error for better user messages
|
||||
let errorMessage = response.statusText;
|
||||
|
||||
try {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
const errorJson = safeParseErrorJson(errorBody);
|
||||
if (errorJson) {
|
||||
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
|
||||
} catch (_parseError) {
|
||||
} else {
|
||||
// If JSON parsing fails, use the raw error body
|
||||
errorMessage = errorBody || response.statusText;
|
||||
}
|
||||
@@ -300,7 +398,7 @@ async function makeRequest<T>(
|
||||
// Retry the original request with the new token
|
||||
return makeRequest<T>(endpoint, options, apiVersion, true);
|
||||
} catch (refreshError) {
|
||||
console.error("Token refresh failed:", refreshError);
|
||||
safeLogError("Token refresh failed:", refreshError);
|
||||
clearAuth();
|
||||
throw new Error("Authentication failed. Please sign in again.");
|
||||
}
|
||||
@@ -329,7 +427,7 @@ async function deleteEventType(eventTypeId: number): Promise<void> {
|
||||
"2024-06-14"
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Delete API error");
|
||||
safeLogError("Delete API error", { error, eventTypeId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -391,7 +489,7 @@ async function rescheduleBooking(
|
||||
}
|
||||
): Promise<Booking> {
|
||||
try {
|
||||
console.log("[CalComAPIService] rescheduleBooking request:", {
|
||||
safeLogInfo("[CalComAPIService] rescheduleBooking request:", {
|
||||
bookingUid,
|
||||
input,
|
||||
});
|
||||
@@ -415,10 +513,10 @@ async function rescheduleBooking(
|
||||
|
||||
throw new Error("Invalid response from reschedule booking API");
|
||||
} catch (error) {
|
||||
console.error("[CalComAPIService] rescheduleBooking error:", error);
|
||||
safeLogError("[CalComAPIService] rescheduleBooking error:", error);
|
||||
if (error instanceof Error) {
|
||||
console.error("[CalComAPIService] Error message:", error.message);
|
||||
console.error("[CalComAPIService] Error stack:", error.stack);
|
||||
safeLogError("[CalComAPIService] Error message:", error.message);
|
||||
safeLogError("[CalComAPIService] Error stack:", error.stack);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -444,7 +542,7 @@ async function confirmBooking(bookingUid: string): Promise<Booking> {
|
||||
|
||||
throw new Error("Invalid response from confirm booking API");
|
||||
} catch (error) {
|
||||
console.error("confirmBooking error");
|
||||
safeLogError("confirmBooking error", { error, bookingUid });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -622,7 +720,7 @@ async function updateLocationV2(
|
||||
|
||||
throw new Error("Invalid response from update location API");
|
||||
} catch (error) {
|
||||
console.error("[CalComAPIService] updateLocationV2 error:", error);
|
||||
safeLogError("[CalComAPIService] updateLocationV2 error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import * as Crypto from "expo-crypto";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { fetchWithTimeout } from "@/utils/network";
|
||||
import { safeLogWarn } from "@/utils/safeLogger";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
// Message types for extension communication
|
||||
@@ -342,14 +345,18 @@ export class CalComOAuthService {
|
||||
tokenRequest: Record<string, string>,
|
||||
tokenEndpoint: string
|
||||
): Promise<OAuthTokens> {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
const response = await fetchWithTimeout(
|
||||
tokenEndpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams(tokenRequest).toString(),
|
||||
},
|
||||
body: new URLSearchParams(tokenRequest).toString(),
|
||||
});
|
||||
30000
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Token exchange failed");
|
||||
@@ -434,7 +441,7 @@ export class CalComOAuthService {
|
||||
|
||||
const sessionToken = await getExtensionSessionToken();
|
||||
if (!sessionToken) {
|
||||
console.warn("No session token available for token sync");
|
||||
safeLogWarn("No session token available for token sync");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -458,7 +465,7 @@ export class CalComOAuthService {
|
||||
if (event.data.success) {
|
||||
resolve();
|
||||
} else {
|
||||
console.warn("Failed to sync tokens to extension:", event.data.error);
|
||||
safeLogWarn("Failed to sync tokens to extension", event.data.error);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
@@ -478,7 +485,7 @@ export class CalComOAuthService {
|
||||
|
||||
const sessionToken = await getExtensionSessionToken();
|
||||
if (!sessionToken) {
|
||||
console.warn("No session token available for token clear");
|
||||
safeLogWarn("No session token available for token clear");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -502,7 +509,7 @@ export class CalComOAuthService {
|
||||
if (event.data.success) {
|
||||
resolve();
|
||||
} else {
|
||||
console.warn("Failed to clear tokens from extension:", event.data.error);
|
||||
safeLogWarn("Failed to clear tokens from extension", event.data.error);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { fetchWithTimeout } from "@/utils/network";
|
||||
|
||||
import type { UserProfile } from "./types/users.types";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
|
||||
export interface WebSessionInfo {
|
||||
isLoggedIn: boolean;
|
||||
accessToken?: string;
|
||||
@@ -73,13 +77,17 @@ async function validateWebSession(): Promise<WebSessionInfo> {
|
||||
|
||||
try {
|
||||
// First try to call the NextAuth session endpoint
|
||||
const sessionResponse = await fetch("https://app.cal.com/api/auth/session", {
|
||||
method: "GET",
|
||||
credentials: "include", // Include cookies
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const sessionResponse = await fetchWithTimeout(
|
||||
"https://app.cal.com/api/auth/session",
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include", // Include cookies
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
});
|
||||
REQUEST_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (sessionResponse.ok) {
|
||||
const sessionData = await sessionResponse.json();
|
||||
@@ -94,13 +102,17 @@ async function validateWebSession(): Promise<WebSessionInfo> {
|
||||
}
|
||||
|
||||
// Try the internal Cal.com me endpoint (this might work with cookies)
|
||||
const meResponse = await fetch("https://app.cal.com/api/me", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const meResponse = await fetchWithTimeout(
|
||||
"https://app.cal.com/api/me",
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
});
|
||||
REQUEST_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (meResponse.ok) {
|
||||
const userData = await meResponse.json();
|
||||
@@ -114,13 +126,17 @@ async function validateWebSession(): Promise<WebSessionInfo> {
|
||||
}
|
||||
|
||||
// Try to check if user is logged in by attempting to access a protected page
|
||||
const dashboardResponse = await fetch("https://app.cal.com/api/trpc/viewer.me", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const dashboardResponse = await fetchWithTimeout(
|
||||
"https://app.cal.com/api/trpc/viewer.me",
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
});
|
||||
REQUEST_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (dashboardResponse.ok) {
|
||||
const dashboardData = await dashboardResponse.json();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { BookingFilter } from "@/hooks";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
|
||||
import { safeLogError, safeLogWarn } from "./safeLogger";
|
||||
|
||||
export const getEmptyStateContent = (activeFilter: BookingFilter) => {
|
||||
switch (activeFilter) {
|
||||
case "upcoming":
|
||||
@@ -48,7 +50,7 @@ export const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.warn("Invalid date string:", dateString);
|
||||
safeLogWarn("Invalid date string provided to formatTime", { dateString });
|
||||
return "";
|
||||
}
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
@@ -57,7 +59,7 @@ export const formatTime = (dateString: string): string => {
|
||||
hour12: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error formatting time:", error, dateString);
|
||||
safeLogError("Error formatting time:", error);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
@@ -75,7 +77,7 @@ export const formatDate = (dateString: string, isUpcoming: boolean): string => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.warn("Invalid date string:", dateString);
|
||||
safeLogWarn("Invalid date string provided to formatDate", { dateString });
|
||||
return "";
|
||||
}
|
||||
const bookingYear = date.getFullYear();
|
||||
@@ -100,7 +102,7 @@ export const formatDate = (dateString: string, isUpcoming: boolean): string => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error formatting date:", error, dateString);
|
||||
safeLogError("Error formatting date:", error);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,9 +59,11 @@ export {
|
||||
validateLocationItem,
|
||||
} from "./locationHelpers";
|
||||
// Network utilities
|
||||
export { isOnline } from "./network";
|
||||
export { fetchWithTimeout, isOnline } from "./network";
|
||||
// Query persistence utilities
|
||||
export { clearQueryCache, createQueryPersister, getCacheMetadata } from "./queryPersister";
|
||||
// Safe logging utilities
|
||||
export { getSafeErrorMessage, safeLogError, safeLogInfo, safeLogWarn } from "./safeLogger";
|
||||
// Slug utilities
|
||||
export { slugify } from "./slugify";
|
||||
// Storage utilities
|
||||
|
||||
@@ -7,6 +7,48 @@
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
/**
|
||||
* Default timeout for fetch requests in milliseconds (30 seconds)
|
||||
*/
|
||||
const DEFAULT_FETCH_TIMEOUT = 30000;
|
||||
|
||||
/**
|
||||
* Fetch with timeout using AbortSignal.
|
||||
* This prevents requests from hanging indefinitely.
|
||||
*
|
||||
* @param url - The URL to fetch
|
||||
* @param options - Fetch options (RequestInit)
|
||||
* @param timeoutMs - Timeout in milliseconds (default: 30000)
|
||||
* @returns Promise<Response>
|
||||
* @throws Error if the request times out or fails
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const response = await fetchWithTimeout('https://api.example.com/data', {
|
||||
* method: 'GET',
|
||||
* headers: { 'Content-Type': 'application/json' }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const fetchWithTimeout = async (
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs: number = DEFAULT_FETCH_TIMEOUT
|
||||
): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the device is currently online
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||
import { CACHE_CONFIG } from "@/config/cache.config";
|
||||
import { safeLogWarn } from "./safeLogger";
|
||||
import { generalStorage } from "./storage";
|
||||
|
||||
// Use the shared general storage adapter for cache persistence
|
||||
@@ -33,7 +34,7 @@ export const createQueryPersister = (): Persister => {
|
||||
const serialized = JSON.stringify(client);
|
||||
await storage.setItem(storageKey, serialized);
|
||||
} catch (error) {
|
||||
console.warn("[QueryPersister] Failed to persist client:", error);
|
||||
safeLogWarn("[QueryPersister] Failed to persist client:", error);
|
||||
// Fail silently - persistence is a nice-to-have, not critical
|
||||
}
|
||||
},
|
||||
@@ -52,7 +53,7 @@ export const createQueryPersister = (): Persister => {
|
||||
|
||||
// Validate timestamp exists and is a valid number
|
||||
if (typeof client.timestamp !== "number" || Number.isNaN(client.timestamp)) {
|
||||
console.warn("[QueryPersister] Invalid or missing timestamp, discarding cache");
|
||||
safeLogWarn("[QueryPersister] Invalid or missing timestamp, discarding cache");
|
||||
await storage.removeItem(storageKey);
|
||||
return undefined;
|
||||
}
|
||||
@@ -68,7 +69,7 @@ export const createQueryPersister = (): Persister => {
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
console.warn("[QueryPersister] Failed to restore client:", error);
|
||||
safeLogWarn("[QueryPersister] Failed to restore client:", error);
|
||||
// If restoration fails, start fresh
|
||||
return undefined;
|
||||
}
|
||||
@@ -81,7 +82,7 @@ export const createQueryPersister = (): Persister => {
|
||||
try {
|
||||
await storage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn("[QueryPersister] Failed to remove client:", error);
|
||||
safeLogWarn("[QueryPersister] Failed to remove client:", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -100,7 +101,7 @@ export const clearQueryCache = async (): Promise<void> => {
|
||||
try {
|
||||
await storage.removeItem(CACHE_CONFIG.persistence.storageKey);
|
||||
} catch (error) {
|
||||
console.warn("[QueryPersister] Failed to clear cache:", error);
|
||||
safeLogWarn("[QueryPersister] Failed to clear cache:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,7 +137,7 @@ export const getCacheMetadata = async (): Promise<{
|
||||
isExpired: age > CACHE_CONFIG.persistence.maxAge,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[QueryPersister] Failed to get cache metadata:", error);
|
||||
safeLogWarn("[QueryPersister] Failed to get cache metadata:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Safe Logger Utility
|
||||
*
|
||||
* Provides secure logging functions that only output in development mode.
|
||||
* This prevents sensitive data from being exposed in production logs.
|
||||
*
|
||||
* Security: All error objects, stack traces, and potentially sensitive data
|
||||
* should be logged using these functions to ensure they are not exposed in production.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely log an error message only in development mode.
|
||||
* Use this instead of console.error when logging error objects that may contain sensitive data.
|
||||
*
|
||||
* @param message - The error message prefix
|
||||
* @param error - Optional error object (will only be logged in __DEV__ mode)
|
||||
*/
|
||||
export const safeLogError = (message: string, error?: unknown): void => {
|
||||
if (__DEV__) {
|
||||
if (error !== undefined) {
|
||||
console.error(message, error);
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely log a warning message only in development mode.
|
||||
* Use this instead of console.warn when logging potentially sensitive data.
|
||||
*
|
||||
* @param message - The warning message prefix
|
||||
* @param data - Optional additional data (will only be logged in __DEV__ mode)
|
||||
*/
|
||||
export const safeLogWarn = (message: string, data?: unknown): void => {
|
||||
if (__DEV__) {
|
||||
if (data !== undefined) {
|
||||
console.warn(message, data);
|
||||
} else {
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely log an info message only in development mode.
|
||||
* Use this instead of console.log when logging potentially sensitive data.
|
||||
*
|
||||
* @param message - The info message prefix
|
||||
* @param data - Optional additional data (will only be logged in __DEV__ mode)
|
||||
*/
|
||||
export const safeLogInfo = (message: string, data?: unknown): void => {
|
||||
if (__DEV__) {
|
||||
if (data !== undefined) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a safe error message for displaying to users.
|
||||
* Returns a generic message instead of exposing raw error details.
|
||||
*
|
||||
* @param error - The error object
|
||||
* @param fallbackMessage - The generic message to show to users
|
||||
* @returns The fallback message (never exposes raw error details to users)
|
||||
*/
|
||||
export const getSafeErrorMessage = (_error: unknown, fallbackMessage: string): string => {
|
||||
return fallbackMessage;
|
||||
};
|
||||
Reference in New Issue
Block a user