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:
Dhairyashil Shinde
2025-12-28 18:48:48 -03:00
committed by GitHub
parent 3fc847c9f7
commit 49ae10149f
31 changed files with 1912 additions and 586 deletions
+5 -1
View File
@@ -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",
+86 -5
View File
@@ -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
+2 -1
View File
@@ -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>
</>
);
}
+11 -86
View File
@@ -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">
+17
View File
@@ -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>
);
}
+162
View File
@@ -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",
+9 -5
View File
@@ -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 }) => (
+28
View File
@@ -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 />
+11 -11
View File
@@ -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);
+3 -2
View File
@@ -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") {
+213
View File
@@ -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>
</>
);
}
+195
View File
@@ -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
View File
@@ -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>
);
}
+1 -1
View File
@@ -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;
}
+84 -80
View File
@@ -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
View File
@@ -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;
}
}
+18 -11
View File
@@ -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();
}
};
+34 -18
View File
@@ -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();
+6 -4
View File
@@ -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 "";
}
};
+3 -1
View File
@@ -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
+42
View File
@@ -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
*
+7 -6
View File
@@ -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;
}
};
+72
View File
@@ -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;
};