feat(companion): Add caching, redesign ui, and refactor code (#25654)

This commit is contained in:
Dhairyashil Shinde
2025-12-14 14:53:38 +00:00
committed by GitHub
parent b9a09117e3
commit c218a0afad
60 changed files with 7907 additions and 3560 deletions
+23
View File
@@ -3,3 +3,26 @@ EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here
# ===========================================
# CACHE CONFIGURATION (all values in MINUTES)
# ===========================================
# Default cache stale time for all queries (default: 5 minutes)
EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES=5
# Garbage collection time - how long to keep unused cache (default: 1440 minutes - 24 hours)
EXPO_PUBLIC_CACHE_GC_TIME_MINUTES=1440
# Bookings cache stale time (default: 5 minutes)
# After this time, data is considered stale and will refetch in background
EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES=5
# Event Types cache stale time (default: -1 = never stale)
# -1 means data only refreshes on mutations (create/update/delete) or manual pull-to-refresh
EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES=-1
# Schedules (Availability) cache stale time (default: -1 = never stale)
EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES=-1
# User Profile cache stale time (default: -1 = never stale)
EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES=-1
+198 -222
View File
@@ -1,11 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter, useFocusEffect } from "expo-router";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "expo-router";
import React, { useState, useMemo } from "react";
import {
View,
Text,
FlatList,
ActivityIndicator,
RefreshControl,
TouchableOpacity,
ActionSheetIOS,
@@ -14,83 +13,76 @@ import {
Modal,
TextInput,
KeyboardAvoidingView,
ScrollView,
} from "react-native";
import { CalComAPIService, Schedule } from "../../services/calcom";
import { Header } from "../../components/Header";
import { FullScreenModal } from "../../components/FullScreenModal";
import { LoadingSpinner } from "../../components/LoadingSpinner";
import { EmptyScreen } from "../../components/EmptyScreen";
import { showErrorAlert } from "../../utils/alerts";
import { offlineAwareRefresh } from "../../utils/network";
import {
useSchedules,
useCreateSchedule,
useDeleteSchedule,
useDuplicateSchedule,
useSetScheduleAsDefault,
} from "../../hooks";
export default function Availability() {
const router = useRouter();
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [filteredSchedules, setFilteredSchedules] = useState<Schedule[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newScheduleName, setNewScheduleName] = useState("");
const [creating, setCreating] = useState(false);
const [showActionsModal, setShowActionsModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const fetchSchedules = async () => {
try {
setError(null);
// Use React Query hooks
const {
data: schedules = [],
isLoading: loading,
isFetching,
error: queryError,
refetch,
} = useSchedules();
// Fetch all schedules
const allSchedules = await CalComAPIService.getSchedules();
// Show refresh indicator when fetching
const refreshing = isFetching && !loading;
// Sort schedules: default first, then by name
const sortedSchedules = allSchedules.sort((a, b) => {
// Default schedule first
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
// Then sort by name alphabetically
return a.name.localeCompare(b.name);
});
const { mutate: createScheduleMutation, isPending: creating } = useCreateSchedule();
const { mutate: deleteScheduleMutation, isPending: deleting } = useDeleteSchedule();
const { mutate: duplicateScheduleMutation } = useDuplicateSchedule();
const { mutate: setAsDefaultMutation } = useSetScheduleAsDefault();
setSchedules(sortedSchedules);
setFilteredSchedules(sortedSchedules);
} catch (err) {
setError("Failed to load availability. Please check your API key and try again.");
} finally {
setLoading(false);
setRefreshing(false);
// Convert query error to string
// Don't show error UI for authentication errors (user will be redirected to login)
// Only show error UI in development mode for other errors
const isAuthError =
queryError?.message?.includes("Authentication") ||
queryError?.message?.includes("sign in") ||
queryError?.message?.includes("401");
const error = queryError && !isAuthError && __DEV__ ? "Failed to load availability." : null;
// Filter schedules based on search query
const filteredSchedules = useMemo(() => {
if (searchQuery.trim() === "") {
return schedules;
}
};
const searchLower = searchQuery.toLowerCase();
return schedules.filter((schedule) => schedule.name.toLowerCase().includes(searchLower));
}, [schedules, searchQuery]);
useEffect(() => {
fetchSchedules();
}, []);
// Note: We don't use useFocusEffect here because schedules have Infinity stale time.
// Data only refreshes on mutations (create/update/delete) or manual pull-to-refresh.
// Refresh schedules when screen comes into focus
useFocusEffect(
useCallback(() => {
// Only refresh if not currently loading (to avoid duplicate calls)
if (!loading && !refreshing) {
fetchSchedules();
}
}, [loading, refreshing])
);
const onRefresh = () => {
setRefreshing(true);
fetchSchedules();
};
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
const handleSearch = (query: string) => {
setSearchQuery(query);
if (query.trim() === "") {
setFilteredSchedules(schedules);
} else {
const filtered = schedules.filter((schedule) =>
schedule.name.toLowerCase().includes(query.toLowerCase())
);
setFilteredSchedules(filtered);
}
};
const handleScheduleLongPress = (schedule: Schedule) => {
@@ -151,22 +143,20 @@ export default function Availability() {
);
};
const handleSetAsDefault = async (schedule: Schedule) => {
try {
await CalComAPIService.updateSchedule(schedule.id, { isDefault: true });
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
}
const handleSetAsDefault = (schedule: Schedule) => {
setAsDefaultMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
},
});
};
const handleDuplicate = async (schedule: Schedule) => {
try {
await CalComAPIService.duplicateSchedule(schedule.id);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to duplicate schedule. Please try again.");
}
const handleDuplicate = (schedule: Schedule) => {
duplicateScheduleMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to duplicate schedule. Please try again.");
},
});
};
const handleDelete = (schedule: Schedule) => {
@@ -180,32 +170,30 @@ export default function Availability() {
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await CalComAPIService.deleteSchedule(schedule.id);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
}
onPress: () => {
deleteScheduleMutation(schedule.id, {
onError: () => {
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
},
});
},
},
]);
}
};
const confirmDelete = async () => {
const confirmDelete = () => {
if (!selectedSchedule) return;
try {
setDeleting(true);
await CalComAPIService.deleteSchedule(selectedSchedule.id);
setShowDeleteModal(false);
setSelectedSchedule(null);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
setDeleting(false);
}
deleteScheduleMutation(selectedSchedule.id, {
onSuccess: () => {
setShowDeleteModal(false);
setSelectedSchedule(null);
},
onError: () => {
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
},
});
};
const handleSchedulePress = (schedule: Schedule) => {
@@ -226,22 +214,20 @@ export default function Availability() {
return;
}
// Get user's timezone (default to America/New_York if not available)
let userTimezone = "America/New_York";
try {
setCreating(true);
// Get user's timezone (default to America/New_York if not available)
let userTimezone = "America/New_York";
try {
const userProfile = await CalComAPIService.getUserProfile();
if (userProfile.timeZone) {
userTimezone = userProfile.timeZone;
}
} catch (error) {
console.log("Could not get user timezone, using default");
const userProfile = await CalComAPIService.getUserProfile();
if (userProfile.timeZone) {
userTimezone = userProfile.timeZone;
}
} catch (error) {
console.log("Could not get user timezone, using default");
}
// Create schedule with Monday-Friday 9 AM - 5 PM default
const newSchedule = await CalComAPIService.createSchedule({
// Create schedule with Monday-Friday 9 AM - 5 PM default
createScheduleMutation(
{
name: newScheduleName.trim(),
timeZone: userTimezone,
isDefault: false,
@@ -252,27 +238,26 @@ export default function Availability() {
endTime: "17:00",
},
],
});
},
{
onSuccess: (newSchedule) => {
setShowCreateModal(false);
setNewScheduleName("");
setShowCreateModal(false);
setNewScheduleName("");
// Navigate to edit the newly created schedule
router.push({
pathname: "/availability-detail",
params: {
id: newSchedule.id.toString(),
// Navigate to edit the newly created schedule
router.push({
pathname: "/availability-detail",
params: {
id: newSchedule.id.toString(),
},
});
},
});
// Refresh the list
fetchSchedules();
} catch (error) {
console.error("Failed to create schedule:", error);
Alert.alert("Error", "Failed to create schedule. Please try again.");
} finally {
setCreating(false);
}
onError: (error) => {
console.error("Failed to create schedule:", error);
showErrorAlert("Error", "Failed to create schedule. Please try again.");
},
}
);
};
const renderSchedule = ({ item: schedule, index }: { item: Schedule; index: number }) => {
@@ -339,8 +324,7 @@ export default function Availability() {
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-[#666]">Loading availability...</Text>
<LoadingSpinner size="large" />
</View>
</View>
);
@@ -356,7 +340,7 @@ export default function Availability() {
Unable to load availability
</Text>
<Text className="mb-6 text-center text-base text-[#666]">{error}</Text>
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={fetchSchedules}>
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={() => refetch()}>
<Text className="text-base font-semibold text-white">Retry</Text>
</TouchableOpacity>
</View>
@@ -364,108 +348,100 @@ export default function Availability() {
);
}
if (schedules.length === 0 && !loading) {
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="calendar-outline" size={64} color="#666" />
<Text className="mb-2 mt-4 text-xl font-bold text-[#333]">No schedules found</Text>
<Text className="text-center text-base text-[#666]">
Create your availability schedule in Cal.com
</Text>
</View>
</View>
);
}
if (filteredSchedules.length === 0 && searchQuery.trim() !== "") {
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="search-outline" size={64} color="#666" />
<Text className="mb-2 mt-4 text-xl font-bold text-[#333]">No results found</Text>
<Text className="text-center text-base text-[#666]">
Try searching with different keywords
</Text>
</View>
</View>
);
}
// Determine what content to show
const showEmptyState = schedules.length === 0 && !loading;
const showSearchEmptyState =
filteredSchedules.length === 0 && searchQuery.trim() !== "" && !showEmptyState;
const showList = !showEmptyState && !showSearchEmptyState;
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<FlatList
data={filteredSchedules}
keyExtractor={(item) => item.id.toString()}
renderItem={renderSchedule}
contentContainerStyle={{ paddingBottom: 90 }}
{/* Empty state - no schedules */}
{showEmptyState && (
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
}}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
/>
>
<EmptyScreen
icon="time-outline"
headline="Create an availability schedule"
description="Creating availability schedules allows you to manage availability across event types. They can be applied to one or more event types."
buttonText="New"
onButtonPress={handleCreateNew}
/>
</ScrollView>
</View>
</View>
)}
{/* Search bar and content for non-empty states */}
{!showEmptyState && (
<>
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
{/* Search empty state */}
{showSearchEmptyState && (
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
}}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</ScrollView>
</View>
)}
{/* Schedules list */}
{showList && (
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<FlatList
data={filteredSchedules}
keyExtractor={(item) => item.id.toString()}
renderItem={renderSchedule}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
)}
</>
)}
{/* Create Schedule Modal */}
<FullScreenModal
@@ -616,8 +592,8 @@ export default function Availability() {
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="trash-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Delete</Text>
<Ionicons name="trash-outline" size={20} color="#800000" />
<Text className="ml-3 text-base text-[#800000]">Delete</Text>
</TouchableOpacity>
</View>
@@ -645,7 +621,7 @@ export default function Availability() {
{/* Icon */}
<View className="mb-4 items-center">
<View className="h-12 w-12 items-center justify-center rounded-full bg-red-100">
<Ionicons name="trash-outline" size={24} color="#EF4444" />
<Ionicons name="trash-outline" size={24} color="#800000" />
</View>
</View>
File diff suppressed because it is too large Load Diff
+155 -227
View File
@@ -1,53 +1,81 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import {
View,
Text,
FlatList,
ScrollView,
ActivityIndicator,
TouchableOpacity,
RefreshControl,
TextInput,
ActionSheetIOS,
Share,
Alert,
Clipboard,
Platform,
Modal,
KeyboardAvoidingView,
Linking,
} from "react-native";
import * as Clipboard from "expo-clipboard";
import Svg, { Path } from "react-native-svg";
import { CalComAPIService, EventType } from "../../services/calcom";
import { Header } from "../../components/Header";
import { Tooltip } from "../../components/Tooltip";
import { FullScreenModal } from "../../components/FullScreenModal";
import { LoadingSpinner } from "../../components/LoadingSpinner";
import { EmptyScreen } from "../../components/EmptyScreen";
import { slugify } from "../../utils/slugify";
import { showErrorAlert } from "../../utils/alerts";
import { offlineAwareRefresh } from "../../utils/network";
import { openInAppBrowser } from "../../utils/browser";
import { formatDuration } from "../../components/event-type-detail/utils";
import {
useEventTypes,
useCreateEventType,
useDeleteEventType,
useDuplicateEventType,
useUsername,
} from "../../hooks";
export default function EventTypes() {
console.log("EventTypes component rendering");
const router = useRouter();
const [eventTypes, setEventTypes] = useState<EventType[]>([]);
const [filteredEventTypes, setFilteredEventTypes] = useState<EventType[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
// Modal state for creating new event type
const [showCreateModal, setShowCreateModal] = useState(false);
const [creating, setCreating] = useState(false);
const [newEventTitle, setNewEventTitle] = useState("");
const [newEventSlug, setNewEventSlug] = useState("");
const [newEventDescription, setNewEventDescription] = useState("");
const [newEventDuration, setNewEventDuration] = useState("15");
const [username, setUsername] = useState<string>("");
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
// Use React Query hooks
const {
data: eventTypes = [],
isLoading: loading,
isFetching,
error: queryError,
refetch,
} = useEventTypes();
// Show refresh indicator when fetching
const refreshing = isFetching && !loading;
const { data: username = "" } = useUsername();
const { mutate: createEventTypeMutation, isPending: creating } = useCreateEventType();
const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType();
const { mutate: duplicateEventTypeMutation } = useDuplicateEventType();
// Convert query error to string
// Don't show error UI for authentication errors (user will be redirected to login)
// Only show error UI in development mode for other errors
const isAuthError =
queryError?.message?.includes("Authentication") ||
queryError?.message?.includes("sign in") ||
queryError?.message?.includes("401");
const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null;
// Modal state for web platform action sheet
const [showActionModal, setShowActionModal] = useState(false);
const [selectedEventType, setSelectedEventType] = useState<EventType | null>(null);
@@ -58,7 +86,6 @@ export default function EventTypes() {
// Modal state for delete confirmation
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [eventTypeToDelete, setEventTypeToDelete] = useState<EventType | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Toast state for web platform
const [showToast, setShowToast] = useState(false);
@@ -78,89 +105,25 @@ export default function EventTypes() {
}, 2000);
};
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Handle pull-to-refresh
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
// Fetch username on mount
useEffect(() => {
const fetchUsername = async () => {
try {
const fetchedUsername = await CalComAPIService.getUsername();
setUsername(fetchedUsername);
} catch (error) {
console.error("Failed to fetch username:", error);
// Keep default username if fetch fails
}
};
fetchUsername();
}, []);
const fetchEventTypes = async () => {
try {
setError(null);
const data = await CalComAPIService.getEventTypes();
if (isMountedRef.current) {
if (Array.isArray(data)) {
setEventTypes(data);
setFilteredEventTypes(data);
} else {
setEventTypes([]);
setFilteredEventTypes([]);
}
}
} catch (err) {
console.error("🎯 EventTypesScreen: Error fetching event types:", err);
if (isMountedRef.current) {
setError("Failed to load event types. Please check your API key and try again.");
}
} finally {
if (isMountedRef.current) {
setLoading(false);
setRefreshing(false);
}
// Filter event types based on search query
const filteredEventTypes = useMemo(() => {
if (searchQuery.trim() === "") {
return eventTypes;
}
};
useEffect(() => {
fetchEventTypes();
}, []);
const onRefresh = () => {
setRefreshing(true);
fetchEventTypes();
};
const searchLower = searchQuery.toLowerCase();
return eventTypes.filter(
(eventType) =>
eventType.title.toLowerCase().includes(searchLower) ||
(eventType.description && eventType.description.toLowerCase().includes(searchLower))
);
}, [eventTypes, searchQuery]);
const handleSearch = (query: string) => {
setSearchQuery(query);
if (query.trim() === "") {
setFilteredEventTypes(eventTypes);
} else {
const filtered = eventTypes.filter(
(eventType) =>
eventType.title.toLowerCase().includes(query.toLowerCase()) ||
(eventType.description &&
eventType.description.toLowerCase().includes(query.toLowerCase()))
);
setFilteredEventTypes(filtered);
}
};
const formatDuration = (minutes: number | undefined) => {
if (!minutes || minutes <= 0) {
return "0m";
}
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
const getDuration = (eventType: EventType): number => {
@@ -259,7 +222,7 @@ export default function EventTypes() {
const handleCopyLink = async (eventType: EventType) => {
try {
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
Clipboard.setString(link);
await Clipboard.setStringAsync(link);
if (Platform.OS === "web") {
showToastMessage("Link copied!", eventType.id);
@@ -270,7 +233,7 @@ export default function EventTypes() {
if (Platform.OS === "web") {
showToastMessage("Failed to copy link");
} else {
Alert.alert("Error", "Failed to copy link. Please try again.");
showErrorAlert("Error", "Failed to copy link. Please try again.");
}
}
};
@@ -283,7 +246,7 @@ export default function EventTypes() {
url: link,
});
} catch (error) {
Alert.alert("Error", "Failed to share link. Please try again.");
showErrorAlert("Error", "Failed to share link. Please try again.");
}
};
@@ -308,20 +271,11 @@ export default function EventTypes() {
setShowDeleteModal(true);
};
const confirmDelete = async () => {
const confirmDelete = () => {
if (!eventTypeToDelete) return;
setIsDeleting(true);
try {
await CalComAPIService.deleteEventType(eventTypeToDelete.id);
// Only update state if component is still mounted
if (isMountedRef.current) {
// Remove the deleted event type from local state
const updatedEventTypes = eventTypes.filter((et) => et.id !== eventTypeToDelete.id);
setEventTypes(updatedEventTypes);
setFilteredEventTypes(updatedEventTypes);
deleteEventTypeMutation(eventTypeToDelete.id, {
onSuccess: () => {
// Close modal and reset state
setShowDeleteModal(false);
setEventTypeToDelete(null);
@@ -331,76 +285,57 @@ export default function EventTypes() {
} else {
Alert.alert("Success", "Event type deleted successfully");
}
}
} catch (error) {
console.error("Failed to delete event type:", error);
if (isMountedRef.current) {
},
onError: (error) => {
console.error("Failed to delete event type:", error);
if (Platform.OS === "web") {
showToastMessage("Failed to delete event type");
} else {
Alert.alert("Error", "Failed to delete event type. Please try again.");
showErrorAlert("Error", "Failed to delete event type. Please try again.");
}
}
} finally {
setIsDeleting(false);
}
},
});
};
const handleDuplicate = async (eventType: EventType) => {
try {
// Generate a new title and slug for the duplicate
const newTitle = `${eventType.title} (copy)`;
let newSlug = `${eventType.slug}-copy`;
const handleDuplicate = (eventType: EventType) => {
duplicateEventTypeMutation(
{ eventType, existingEventTypes: eventTypes },
{
onSuccess: (duplicatedEventType) => {
if (Platform.OS === "web") {
showToastMessage("Event type duplicated successfully");
} else {
Alert.alert("Success", "Event type duplicated successfully");
}
// Check if slug already exists and append a number if needed
let counter = 1;
while (eventTypes.some((et) => et.slug === newSlug)) {
newSlug = `${eventType.slug}-copy-${counter}`;
counter++;
}
const duration = getDuration(eventType);
const duration = getDuration(eventType);
// Create the duplicate event type
const duplicatedEventType = await CalComAPIService.createEventType({
title: newTitle,
slug: newSlug,
lengthInMinutes: duration,
description: eventType.description || undefined,
});
// Refresh the list
await fetchEventTypes();
if (Platform.OS === "web") {
showToastMessage("Event type duplicated successfully");
} else {
Alert.alert("Success", "Event type duplicated successfully");
}
// Navigate to edit the newly created duplicate
router.push({
pathname: "/event-type-detail",
params: {
id: duplicatedEventType.id.toString(),
title: duplicatedEventType.title,
description: duplicatedEventType.description || "",
duration: (
duplicatedEventType.lengthInMinutes ||
duplicatedEventType.length ||
duration
).toString(),
slug: duplicatedEventType.slug || "",
// Navigate to edit the newly created duplicate
router.push({
pathname: "/event-type-detail",
params: {
id: duplicatedEventType.id.toString(),
title: duplicatedEventType.title,
description: duplicatedEventType.description || "",
duration: (
duplicatedEventType.lengthInMinutes ||
duplicatedEventType.length ||
duration
).toString(),
slug: duplicatedEventType.slug || "",
},
});
},
onError: (error) => {
console.error("Failed to duplicate event type:", error);
if (Platform.OS === "web") {
showToastMessage("Failed to duplicate event type");
} else {
showErrorAlert("Error", "Failed to duplicate event type. Please try again.");
}
},
});
} catch (error) {
console.error("Failed to duplicate event type:", error);
if (Platform.OS === "web") {
showToastMessage("Failed to duplicate event type");
} else {
Alert.alert("Error", "Failed to duplicate event type. Please try again.");
}
}
);
};
const handlePreview = async (eventType: EventType) => {
@@ -410,15 +345,15 @@ export default function EventTypes() {
if (Platform.OS === "web") {
window.open(link, "_blank");
} else {
// For mobile, use Linking
await Linking.openURL(link);
// For mobile, use in-app browser
await openInAppBrowser(link, "event type preview");
}
} catch (error) {
console.error("Failed to open preview:", error);
if (Platform.OS === "web") {
showToastMessage("Failed to open preview");
} else {
Alert.alert("Error", "Failed to open preview. Please try again.");
showErrorAlert("Error", "Failed to open preview. Please try again.");
}
}
};
@@ -440,7 +375,7 @@ export default function EventTypes() {
setIsSlugManuallyEdited(false);
};
const handleCreateEventType = async () => {
const handleCreateEventType = () => {
if (!newEventTitle.trim()) {
Alert.alert("Error", "Please enter a title for your event type");
return;
@@ -457,39 +392,36 @@ export default function EventTypes() {
return;
}
setCreating(true);
try {
// Create the event type with the form data
const newEventType = await CalComAPIService.createEventType({
createEventTypeMutation(
{
title: newEventTitle.trim(),
slug: newEventSlug.trim(),
lengthInMinutes: duration,
description: newEventDescription.trim() || undefined,
});
},
{
onSuccess: (newEventType) => {
// Close modal and reset form
handleCloseCreateModal();
// Close modal and reset form
handleCloseCreateModal();
// Refresh the list
await fetchEventTypes();
// Navigate to edit the newly created event type
router.push({
pathname: "/event-type-detail",
params: {
id: newEventType.id.toString(),
title: newEventType.title,
description: newEventType.description || "",
duration: (newEventType.lengthInMinutes || newEventType.length || 15).toString(),
slug: newEventType.slug || "",
// Navigate to edit the newly created event type
router.push({
pathname: "/event-type-detail",
params: {
id: newEventType.id.toString(),
title: newEventType.title,
description: newEventType.description || "",
duration: (newEventType.lengthInMinutes || newEventType.length || 15).toString(),
slug: newEventType.slug || "",
},
});
},
});
} catch (error) {
console.error("Failed to create event type:", error);
Alert.alert("Error", "Failed to create event type. Please try again.");
} finally {
setCreating(false);
}
onError: (error) => {
console.error("Failed to create event type:", error);
showErrorAlert("Error", "Failed to create event type. Please try again.");
},
}
);
};
const renderEventType = ({ item, index }: { item: EventType; index: number }) => {
@@ -590,8 +522,7 @@ export default function EventTypes() {
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-gray-500">Loading event types...</Text>
<LoadingSpinner size="large" />
</View>
</View>
);
@@ -607,7 +538,7 @@ export default function EventTypes() {
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={fetchEventTypes}>
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={() => refetch()}>
<Text className="text-base font-semibold text-white">Retry</Text>
</TouchableOpacity>
</View>
@@ -619,24 +550,14 @@ export default function EventTypes() {
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-2 py-2 md:px-4">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search event types"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="calendar-outline" size={64} color="#666" />
<Text className="mb-2 mt-4 text-xl font-bold text-gray-800">No event types found</Text>
<Text className="text-center text-base text-gray-500">
Create your first event type in Cal.com
</Text>
<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={handleCreateNew}
/>
</View>
</View>
);
@@ -646,7 +567,7 @@ export default function EventTypes() {
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-2 py-2 md:px-4">
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search event types"
@@ -657,13 +578,20 @@ export default function EventTypes() {
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="search-outline" size={64} color="#666" />
<Text className="mb-2 mt-4 text-xl font-bold text-gray-800">No results found</Text>
<Text className="text-center text-base text-gray-500">
Try searching with different keywords
</Text>
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</View>
</View>
);
@@ -914,8 +842,8 @@ export default function EventTypes() {
if (eventType) handleDelete(eventType);
}}
>
<Ionicons name="trash-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Delete</Text>
<Ionicons name="trash-outline" size={20} color="#800000" />
<Text className="ml-3 text-base text-[#800000]">Delete</Text>
</TouchableOpacity>
</View>
@@ -1007,7 +935,7 @@ export default function EventTypes() {
<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="#DC2626" />
<Ionicons name="alert-circle" size={20} color="#800000" />
</View>
{/* Title and description */}
+46 -26
View File
@@ -1,9 +1,11 @@
import React from "react";
import { View, Text, TouchableOpacity, ScrollView, Linking, Alert } from "react-native";
import { View, Text, TouchableOpacity, ScrollView, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { Header } from "../../components/Header";
import { LogoutButton } from "../../components/LogoutButton";
import { useAuth } from "../../contexts/AuthContext";
import { showErrorAlert } from "../../utils/alerts";
import { openInAppBrowser } from "../../utils/browser";
interface MoreMenuItem {
name: string;
@@ -15,19 +17,24 @@ interface MoreMenuItem {
export default function More() {
const router = useRouter();
const { logout } = useAuth();
const openExternalLink = async (url: string, fallbackMessage: string) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`);
}
} catch (error) {
console.error(`Failed to open ${url}:`, error);
Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`);
}
const handleSignOut = () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try {
await logout();
} catch (error) {
console.error("Logout error:", error);
showErrorAlert("Error", "Failed to sign out. Please try again.");
}
},
},
]);
};
const menuItems: MoreMenuItem[] = [
@@ -36,37 +43,37 @@ export default function More() {
icon: "person-outline",
isExternal: true,
onPress: () =>
openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page"),
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"),
},
{
name: "Apps",
icon: "grid-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/apps", "Apps page"),
onPress: () => openInAppBrowser("https://app.cal.com/apps", "Apps page"),
},
{
name: "Routing",
icon: "git-branch-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/routing", "Routing page"),
onPress: () => openInAppBrowser("https://app.cal.com/routing", "Routing page"),
},
{
name: "Workflows",
icon: "flash-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/workflows", "Workflows page"),
onPress: () => openInAppBrowser("https://app.cal.com/workflows", "Workflows page"),
},
{
name: "Insights",
icon: "bar-chart-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/insights", "Insights page"),
onPress: () => openInAppBrowser("https://app.cal.com/insights", "Insights page"),
},
{
name: "Support",
icon: "help-circle-outline",
isExternal: true,
onPress: () => openExternalLink("https://go.cal.com/support", "Support"),
onPress: () => openInAppBrowser("https://go.cal.com/support", "Support"),
},
];
@@ -99,14 +106,27 @@ export default function More() {
))}
</View>
{/* Authentication Info and Logout */}
<View className="py-4">
<LogoutButton className="w-full bg-transparent text-gray-900" />
{/* 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>
<Text className="mt-4 text-sm text-gray-500">
We view the companion as an extension of the web application. If you are performing any
complicated actions, please refer back to the web application.
{/* Footer Note */}
<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>
</View>
+8 -3
View File
@@ -1,6 +1,8 @@
import { Stack } from "expo-router";
import { Platform, View, StatusBar } from "react-native";
import { AuthProvider, useAuth } from "../contexts/AuthContext";
import { QueryProvider } from "../contexts/QueryContext";
import { NetworkStatusBanner } from "../components/NetworkStatusBanner";
import LoginScreen from "../components/LoginScreen";
import "../global.css";
@@ -35,14 +37,17 @@ function RootLayoutContent() {
<View style={containerStyle} className={containerClass}>
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
{content}
<NetworkStatusBanner />
</View>
);
}
export default function RootLayout() {
return (
<AuthProvider>
<RootLayoutContent />
</AuthProvider>
<QueryProvider>
<AuthProvider>
<RootLayoutContent />
</AuthProvider>
</QueryProvider>
);
}
+5 -4
View File
@@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, Schedule } from "../services/calcom";
import { ScheduleAvailability } from "../services/types";
import { FullScreenModal } from "../components/FullScreenModal";
import { showErrorAlert } from "../utils/alerts";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -220,7 +221,7 @@ export default function AvailabilityDetail() {
}
} catch (error) {
console.error("Error fetching schedule:", error);
Alert.alert("Error", "Failed to load schedule. Please try again.");
showErrorAlert("Error", "Failed to load schedule. Please try again.");
router.back();
} finally {
setLoading(false);
@@ -348,7 +349,7 @@ export default function AvailabilityDetail() {
{ text: "OK", onPress: () => router.back() },
]);
} catch (error) {
Alert.alert("Error", "Failed to update schedule. Please try again.");
showErrorAlert("Error", "Failed to update schedule. Please try again.");
} finally {
setSaving(false);
}
@@ -362,7 +363,7 @@ export default function AvailabilityDetail() {
setIsDefault(true);
Alert.alert("Success", "Schedule set as default successfully");
} catch (error) {
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
}
};
@@ -379,7 +380,7 @@ export default function AvailabilityDetail() {
{ text: "OK", onPress: () => router.back() },
]);
} catch (error) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
}
},
},
+167 -226
View File
@@ -8,16 +8,19 @@ import {
TouchableOpacity,
Alert,
ActivityIndicator,
Linking,
TextInput,
Platform,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, Booking } from "../services/calcom";
import { showErrorAlert } from "../utils/alerts";
import { openInAppBrowser } from "../utils/browser";
import { SvgImage } from "../components/SvgImage";
import { FullScreenModal } from "../components/FullScreenModal";
import { BookingActionsModal } from "../components/BookingActionsModal";
import { getAppIconUrl } from "../utils/getAppIconUrl";
import { getDefaultLocationIconUrl, defaultLocations } from "../utils/defaultLocations";
import { formatAppIdToDisplayName } from "../utils/formatters";
// Format date: "Tuesday, November 25, 2025"
const formatDateFull = (dateString: string): string => {
@@ -137,14 +140,6 @@ const getLocationProvider = (location: string | undefined, metadata?: Record<str
const iconUrl = getAppIconUrl("", appId);
if (iconUrl) {
// Format appId to display name (e.g., "cal-video" -> "Cal Video")
const formatAppIdToDisplayName = (id: string): string => {
return id
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
return {
label: formatAppIdToDisplayName(appId),
iconUrl: iconUrl,
@@ -181,6 +176,8 @@ export default function BookingDetail() {
const [error, setError] = useState<string | null>(null);
const [showActionsModal, setShowActionsModal] = useState(false);
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
const [rescheduleDate, setRescheduleDate] = useState("");
const [rescheduleTime, setRescheduleTime] = useState("");
const [rescheduleReason, setRescheduleReason] = useState("");
const [rescheduling, setRescheduling] = useState(false);
@@ -208,9 +205,13 @@ export default function BookingDetail() {
} catch (err) {
console.error("Error fetching booking:", err);
setError("Failed to load booking. Please try again.");
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
if (__DEV__) {
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} else {
router.back();
}
} finally {
setLoading(false);
}
@@ -221,31 +222,70 @@ export default function BookingDetail() {
const provider = getLocationProvider(booking.location);
if (provider?.url) {
Linking.openURL(provider.url);
openInAppBrowser(provider.url, "meeting link");
}
};
const handleReschedule = async () => {
const openRescheduleModal = () => {
if (!booking) return;
// Pre-fill with the current booking date/time (using local timezone consistently)
const currentDate = new Date(booking.startTime);
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`;
const timeStr = `${String(currentDate.getHours()).padStart(2, "0")}:${String(currentDate.getMinutes()).padStart(2, "0")}`;
setRescheduleDate(dateStr);
setRescheduleTime(timeStr);
setRescheduleReason("");
setShowRescheduleModal(true);
};
const handleReschedule = async () => {
if (!booking || !rescheduleDate || !rescheduleTime) {
showErrorAlert("Error", "Please enter both date and time");
return;
}
// Parse the date and time
const dateTimeStr = `${rescheduleDate}T${rescheduleTime}:00`;
const newDateTime = new Date(dateTimeStr);
// Validate the date
if (isNaN(newDateTime.getTime())) {
showErrorAlert(
"Error",
"Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time."
);
return;
}
// Check if the new time is in the future
if (newDateTime <= new Date()) {
showErrorAlert("Error", "Please select a future date and time");
return;
}
// Convert to UTC ISO string
const startUtc = newDateTime.toISOString();
setRescheduling(true);
try {
// For now, we'll use the current start time
// In a full implementation, you'd show a date/time picker
await CalComAPIService.rescheduleBooking(booking.uid, {
start: booking.startTime,
start: startUtc,
reschedulingReason: rescheduleReason.trim() || undefined,
});
Alert.alert("Success", "Reschedule request sent successfully");
Alert.alert("Success", "Booking rescheduled successfully");
setShowRescheduleModal(false);
setRescheduleDate("");
setRescheduleTime("");
setRescheduleReason("");
// Refresh booking data
await fetchBooking();
} catch (error) {
console.error("Failed to reschedule booking:", error);
Alert.alert("Error", "Failed to send reschedule request. Please try again.");
showErrorAlert("Error", "Failed to reschedule booking. Please try again.");
} finally {
setRescheduling(false);
}
@@ -470,193 +510,51 @@ export default function BookingDetail() {
</View>
{/* Booking Actions Modal */}
<FullScreenModal
<BookingActionsModal
visible={showActionsModal}
animationType="fade"
onRequestClose={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="border-b border-gray-200 p-6">
<Text className="text-center text-xl font-semibold text-gray-900">
Booking Actions
</Text>
</View>
{/* Actions List */}
<View className="p-2">
{/* View Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Navigate to booking view page
console.log("View booking");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="eye-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">View Booking</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Edit event label */}
<View className="px-4 py-1">
<Text className="text-xs font-medium text-gray-500">Edit event</Text>
</View>
{/* Request Reschedule */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
setShowRescheduleModal(true);
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="send-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Send Reschedule Request</Text>
</TouchableOpacity>
{/* Edit Location */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open edit location dialog
console.log("Edit location");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="location-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Edit Location</Text>
</TouchableOpacity>
{/* Add Guests */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open add guests dialog
console.log("Add guests");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="person-add-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Add Guests</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* After event label */}
<View className="px-4 py-1">
<Text className="text-xs font-medium text-gray-500">After event</Text>
</View>
{/* View Recordings */}
{locationProvider?.url && (
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open view recordings dialog
console.log("View recordings");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="videocam-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">View Recordings</Text>
</TouchableOpacity>
)}
{/* Meeting Session Details */}
{locationProvider?.url && (
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open session details dialog
console.log("Meeting session details");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="information-circle-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Meeting Session Details</Text>
</TouchableOpacity>
)}
{/* Mark as No-Show */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Mark as no-show
console.log("Mark as no-show");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="eye-off-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Mark as No-Show</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Report Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open report booking dialog
console.log("Report booking");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="flag-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Report Booking</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Cancel Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [
{ text: "No", style: "cancel" },
{
text: "Yes, Cancel",
style: "destructive",
onPress: () => {
// TODO: Cancel booking
console.log("Cancel booking");
},
},
]);
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="close-circle-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Cancel Booking</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<TouchableOpacity
className="w-full rounded-lg bg-gray-100 p-3"
onPress={() => setShowActionsModal(false)}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
onClose={() => setShowActionsModal(false)}
booking={booking}
hasLocationUrl={!!locationProvider?.url}
isUpcoming={booking ? new Date(booking.startTime) > new Date() : false}
isPast={booking ? new Date(booking.startTime) <= new Date() : false}
isCancelled={booking?.status?.toUpperCase() === "CANCELLED"}
isUnconfirmed={booking?.status?.toUpperCase() === "PENDING"}
onReschedule={openRescheduleModal}
onEditLocation={() => {
Alert.alert("Edit Location", "Edit location functionality coming soon");
}}
onAddGuests={() => {
Alert.alert("Add Guests", "Add guests functionality coming soon");
}}
onViewRecordings={() => {
Alert.alert("View Recordings", "View recordings functionality coming soon");
}}
onMeetingSessionDetails={() => {
Alert.alert(
"Meeting Session Details",
"Meeting session details functionality coming soon"
);
}}
onMarkNoShow={() => {
Alert.alert("Mark as No-Show", "Mark as no-show functionality coming soon");
}}
onReportBooking={() => {
Alert.alert("Report Booking", "Report booking functionality coming soon");
}}
onCancelBooking={() => {
Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [
{ text: "No", style: "cancel" },
{
text: "Yes, Cancel",
style: "destructive",
onPress: () => {
// TODO: Implement cancel booking
console.log("Cancel booking");
},
},
]);
}}
/>
{/* Reschedule Modal */}
<FullScreenModal
@@ -685,37 +583,74 @@ export default function BookingDetail() {
<View className="px-8 pb-4 pt-6">
<View className="flex-row items-start space-x-3">
<View className="h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#F3F4F6]">
<Ionicons name="time-outline" size={24} color="#111827" />
<Ionicons name="calendar-outline" size={24} color="#111827" />
</View>
<View className="flex-1 pt-1">
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
Reschedule request
Reschedule Booking
</Text>
<Text className="text-sm text-[#6B7280]">
Send a reschedule request to the organizer of this booking.
Select a new date and time for this booking.
</Text>
</View>
</View>
</View>
{/* Content */}
<View className="px-8 pb-6">
<Text className="mb-2 text-sm font-bold text-[#111827]">
Reason for reschedule request
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
</Text>
<TextInput
className="min-h-[100px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="Please let us know why you need to reschedule..."
placeholderTextColor="#9CA3AF"
value={rescheduleReason}
onChangeText={setRescheduleReason}
multiline
numberOfLines={4}
textAlignVertical="top"
editable={!rescheduling}
/>
</View>
<ScrollView className="max-h-[400px] px-8 pb-6">
{/* Date Input */}
<View className="mb-4">
<Text className="mb-2 text-sm font-bold text-[#111827]">
New Date
<Text className="font-normal text-[#6B7280]"> (YYYY-MM-DD)</Text>
</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="2024-12-25"
placeholderTextColor="#9CA3AF"
value={rescheduleDate}
onChangeText={setRescheduleDate}
editable={!rescheduling}
keyboardType="default"
/>
</View>
{/* Time Input */}
<View className="mb-4">
<Text className="mb-2 text-sm font-bold text-[#111827]">
New Time
<Text className="font-normal text-[#6B7280]"> (HH:MM, 24-hour)</Text>
</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="14:30"
placeholderTextColor="#9CA3AF"
value={rescheduleTime}
onChangeText={setRescheduleTime}
editable={!rescheduling}
keyboardType="default"
/>
</View>
{/* Reason Input */}
<View className="mb-2">
<Text className="mb-2 text-sm font-bold text-[#111827]">
Reason
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
</Text>
<TextInput
className="min-h-[80px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="Enter reason for rescheduling..."
placeholderTextColor="#9CA3AF"
value={rescheduleReason}
onChangeText={setRescheduleReason}
multiline
numberOfLines={3}
textAlignVertical="top"
editable={!rescheduling}
/>
</View>
</ScrollView>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
@@ -724,6 +659,8 @@ export default function BookingDetail() {
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
setShowRescheduleModal(false);
setRescheduleDate("");
setRescheduleTime("");
setRescheduleReason("");
}}
disabled={rescheduling}
@@ -735,7 +672,11 @@ export default function BookingDetail() {
onPress={handleReschedule}
disabled={rescheduling}
>
<Text className="text-base font-medium text-white">Reschedule request</Text>
{rescheduling ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-base font-medium text-white">Reschedule</Text>
)}
</TouchableOpacity>
</View>
</View>
File diff suppressed because it is too large Load Diff
@@ -1,521 +0,0 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface AdvancedTabProps {
// Calendar event name
calendarEventName: string;
setCalendarEventName: (value: string) => void;
// Add to calendar email
addToCalendarEmail: string;
setAddToCalendarEmail: (value: string) => void;
// Layout
selectedLayouts: string[];
setSelectedLayouts: (layouts: string[]) => void;
defaultLayout: string;
setDefaultLayout: (layout: string) => void;
// Confirmation settings
requiresConfirmation: boolean;
setRequiresConfirmation: (value: boolean) => void;
disableCancelling: boolean;
setDisableCancelling: (value: boolean) => void;
disableRescheduling: boolean;
setDisableRescheduling: (value: boolean) => void;
// Additional settings
sendCalVideoTranscription: boolean;
setSendCalVideoTranscription: (value: boolean) => void;
autoTranslate: boolean;
setAutoTranslate: (value: boolean) => void;
requiresBookerEmailVerification: boolean;
setRequiresBookerEmailVerification: (value: boolean) => void;
hideCalendarNotes: boolean;
setHideCalendarNotes: (value: boolean) => void;
hideCalendarEventDetails: boolean;
setHideCalendarEventDetails: (value: boolean) => void;
hideOrganizerEmail: boolean;
setHideOrganizerEmail: (value: boolean) => void;
lockTimezone: boolean;
setLockTimezone: (value: boolean) => void;
allowReschedulingPastEvents: boolean;
setAllowReschedulingPastEvents: (value: boolean) => void;
allowBookingThroughRescheduleLink: boolean;
setAllowBookingThroughRescheduleLink: (value: boolean) => void;
// Redirect on booking
successRedirectUrl: string;
setSuccessRedirectUrl: (value: string) => void;
forwardParamsSuccessRedirect: boolean;
setForwardParamsSuccessRedirect: (value: boolean) => void;
// Custom reply-to email
customReplyToEmail: string;
setCustomReplyToEmail: (value: string) => void;
// Event type colors
eventTypeColorLight: string;
setEventTypeColorLight: (value: string) => void;
eventTypeColorDark: string;
setEventTypeColorDark: (value: string) => void;
}
export function AdvancedTab(props: AdvancedTabProps) {
const layoutOptions = [
{ id: "MONTH_VIEW", label: "Month", icon: "calendar-outline" },
{ id: "WEEK_VIEW", label: "Weekly", icon: "calendar-outline" },
{ id: "COLUMN_VIEW", label: "Column", icon: "list-outline" },
];
return (
<View className="gap-3">
{/* Calendar Event Name Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Calendar event name</Text>
<TextInput
className="mb-2 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.calendarEventName}
onChangeText={props.setCalendarEventName}
placeholder="30min between Pro Example and {Scheduler}"
placeholderTextColor="#8E8E93"
/>
<Text className="text-xs text-[#666]">
Use variables like {"{"}Scheduler{"}"} for booker name, {"{"}Organizer{"}"} for your name
</Text>
</View>
{/* Add to Calendar Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Add to calendar</Text>
<Text className="mb-3 text-sm text-[#666]">
We'll display this email address as the organizer, and send confirmation emails here.
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.addToCalendarEmail}
onChangeText={props.setAddToCalendarEmail}
placeholder="pro@example.com"
placeholderTextColor="#8E8E93"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* Layout Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Layout</Text>
<Text className="mb-3 text-sm text-[#666]">
You can select multiple and your bookers can switch views.
</Text>
{/* Layout Options */}
<View className="mb-4 gap-2">
{layoutOptions.map((layout) => (
<TouchableOpacity
key={layout.id}
className={`flex-row items-center justify-between rounded-lg border p-3 ${
props.selectedLayouts.includes(layout.id)
? "border-black bg-[#F0F0F0]"
: "border-[#E5E5EA]"
}`}
onPress={() => {
if (props.selectedLayouts.includes(layout.id)) {
// Don't allow deselecting if it's the only one
if (props.selectedLayouts.length > 1) {
props.setSelectedLayouts(props.selectedLayouts.filter((l) => l !== layout.id));
// If removing the default, set a new default
if (props.defaultLayout === layout.id) {
const remaining = props.selectedLayouts.filter((l) => l !== layout.id);
props.setDefaultLayout(remaining[0]);
}
}
} else {
props.setSelectedLayouts([...props.selectedLayouts, layout.id]);
}
}}
>
<View className="flex-row items-center gap-2">
<Ionicons name={layout.icon as any} size={20} color="#333" />
<Text className="text-base text-[#333]">{layout.label}</Text>
</View>
{props.selectedLayouts.includes(layout.id) && (
<Ionicons name="checkmark-circle" size={20} color="#000" />
)}
</TouchableOpacity>
))}
</View>
{/* Default View */}
{props.selectedLayouts.length > 1 && (
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Default view</Text>
<View className="gap-2">
{props.selectedLayouts.map((layoutId) => {
const layout = layoutOptions.find((l) => l.id === layoutId);
if (!layout) return null;
return (
<TouchableOpacity
key={layout.id}
className={`flex-row items-center justify-between rounded-lg border p-3 ${
props.defaultLayout === layout.id
? "border-black bg-[#F0F0F0]"
: "border-[#E5E5EA]"
}`}
onPress={() => props.setDefaultLayout(layout.id)}
>
<Text className="text-base text-[#333]">{layout.label}</Text>
{props.defaultLayout === layout.id && (
<Ionicons name="checkmark-circle" size={20} color="#000" />
)}
</TouchableOpacity>
);
})}
</View>
</View>
)}
</View>
{/* Booking Questions Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Booking questions</Text>
<Text className="mb-3 text-sm text-[#666]">
Customize the questions asked on the booking page.
</Text>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 md:px-4"
onPress={() =>
Alert.alert("Coming Soon", "Booking questions customization will be available soon.")
}
>
<Ionicons name="add-circle-outline" size={20} color="#666" />
<Text className="ml-2 text-base text-[#666]">Manage booking questions</Text>
</TouchableOpacity>
</View>
{/* Requires confirmation */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Requires confirmation</Text>
<Text className="text-sm text-[#666]">
The booking needs to be manually confirmed before it is pushed to your calendar
</Text>
</View>
<Switch
value={props.requiresConfirmation}
onValueChange={props.setRequiresConfirmation}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Disable Cancelling */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Disable Cancelling</Text>
<Text className="text-sm text-[#666]">
Guests can no longer cancel the event with calendar invite or email
</Text>
</View>
<Switch
value={props.disableCancelling}
onValueChange={props.setDisableCancelling}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Disable Rescheduling */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Disable Rescheduling</Text>
<Text className="text-sm text-[#666]">
Guests can no longer reschedule the event with calendar invite or email
</Text>
</View>
<Switch
value={props.disableRescheduling}
onValueChange={props.setDisableRescheduling}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Send Cal Video Transcription Emails */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Send Cal Video Transcription Emails
</Text>
<Text className="text-sm text-[#666]">
Send emails with the transcription of the Cal Video after the meeting ends
</Text>
</View>
<Switch
value={props.sendCalVideoTranscription}
onValueChange={props.setSendCalVideoTranscription}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Auto translate */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Auto translate title and description
</Text>
<Text className="text-sm text-[#666]">
Automatically translate titles and descriptions to the visitor's browser language
using AI
</Text>
</View>
<Switch
value={props.autoTranslate}
onValueChange={props.setAutoTranslate}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Requires booker email verification */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Requires booker email verification
</Text>
<Text className="text-sm text-[#666]">
To ensure booker's email verification before scheduling events
</Text>
</View>
<Switch
value={props.requiresBookerEmailVerification}
onValueChange={props.setRequiresBookerEmailVerification}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide notes in calendar */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Hide notes in calendar</Text>
<Text className="text-sm text-[#666]">
For privacy reasons, additional inputs and notes will be hidden in the calendar entry
</Text>
</View>
<Switch
value={props.hideCalendarNotes}
onValueChange={props.setHideCalendarNotes}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide calendar event details */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Hide calendar event details on shared calendars
</Text>
<Text className="text-sm text-[#666]">
When a calendar is shared, events are visible but details are hidden from those
without write access
</Text>
</View>
<Switch
value={props.hideCalendarEventDetails}
onValueChange={props.setHideCalendarEventDetails}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide organizer's email */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Hide organizer's email</Text>
<Text className="text-sm text-[#666]">
Hide organizer's email address from the booking screen, email notifications, and
calendar events
</Text>
</View>
<Switch
value={props.hideOrganizerEmail}
onValueChange={props.setHideOrganizerEmail}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Lock timezone */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Lock timezone on booking page
</Text>
<Text className="text-sm text-[#666]">
To lock the timezone on booking page, useful for in-person events
</Text>
</View>
<Switch
value={props.lockTimezone}
onValueChange={props.setLockTimezone}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Allow rescheduling past events */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Allow rescheduling past events
</Text>
<Text className="text-sm text-[#666]">
Enabling this option allows for past events to be rescheduled
</Text>
</View>
<Switch
value={props.allowReschedulingPastEvents}
onValueChange={props.setAllowReschedulingPastEvents}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Allow booking through reschedule link */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Allow booking through reschedule link
</Text>
<Text className="text-sm text-[#666]">
When enabled, users will be able to create a new booking when trying to reschedule a
cancelled booking
</Text>
</View>
<Switch
value={props.allowBookingThroughRescheduleLink}
onValueChange={props.setAllowBookingThroughRescheduleLink}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Redirect on booking Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Redirect on booking</Text>
<Text className="mb-3 text-sm text-[#666]">
Redirect to a custom URL after a successful booking
</Text>
<TextInput
className="mb-3 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.successRedirectUrl}
onChangeText={props.setSuccessRedirectUrl}
placeholder="https://example.com/thank-you"
placeholderTextColor="#8E8E93"
keyboardType="url"
autoCapitalize="none"
/>
<View className="flex-row items-center justify-between">
<Text className="text-sm text-[#333]">Forward query parameters</Text>
<Switch
value={props.forwardParamsSuccessRedirect}
onValueChange={props.setForwardParamsSuccessRedirect}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Custom Reply-To Email Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Custom 'Reply-To' email</Text>
<Text className="mb-3 text-sm text-[#666]">
Use a different email address as the replyTo for confirmation emails instead of the
organizer's email
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.customReplyToEmail}
onChangeText={props.setCustomReplyToEmail}
placeholder="reply@example.com"
placeholderTextColor="#8E8E93"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* Event Type Color Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Event type color</Text>
<Text className="mb-3 text-sm text-[#666]">
This is only used for event type & booking differentiation within the app. It is not
displayed to bookers.
</Text>
<View className="gap-3">
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Light theme color</Text>
<View className="flex-row items-center gap-3">
<View
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
style={{ backgroundColor: props.eventTypeColorLight }}
/>
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTypeColorLight}
onChangeText={props.setEventTypeColorLight}
placeholder="#292929"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
/>
</View>
</View>
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Dark theme color</Text>
<View className="flex-row items-center gap-3">
<View
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
style={{ backgroundColor: props.eventTypeColorDark }}
/>
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTypeColorDark}
onChangeText={props.setEventTypeColorDark}
placeholder="#FAFAFA"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
/>
</View>
</View>
</View>
</View>
</View>
);
}
@@ -1,137 +0,0 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface RecurringTabProps {
recurringEnabled: boolean;
setRecurringEnabled: (value: boolean) => void;
recurringInterval: string;
setRecurringInterval: (value: string) => void;
recurringFrequency: "daily" | "weekly" | "monthly" | "yearly";
setRecurringFrequency: (value: "daily" | "weekly" | "monthly" | "yearly") => void;
recurringOccurrences: string;
setRecurringOccurrences: (value: string) => void;
}
export function RecurringTab({
recurringEnabled,
setRecurringEnabled,
recurringInterval,
setRecurringInterval,
recurringFrequency,
setRecurringFrequency,
recurringOccurrences,
setRecurringOccurrences,
}: RecurringTabProps) {
return (
<View className="gap-3">
{/* Recurring Event Toggle Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Recurring event</Text>
<Text className="text-sm text-[#666]">
Set up this event type to repeat at regular intervals
</Text>
</View>
<Switch
value={recurringEnabled}
onValueChange={setRecurringEnabled}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Recurring Configuration Card - shown when enabled */}
{recurringEnabled && (
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-4 text-base font-semibold text-[#333]">Recurrence pattern</Text>
{/* Repeats Every */}
<View className="mb-4">
<Text className="mb-2 text-sm font-medium text-[#333]">Repeats every</Text>
<View className="flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={recurringInterval}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
// Don't allow empty or 0 values; fall back to 1 so users can keep editing
if (numericValue === "" || numericValue === "0") {
setRecurringInterval("1");
return;
}
const num = parseInt(numericValue);
if (num >= 1 && num <= 20) {
setRecurringInterval(numericValue);
}
}}
placeholder="1"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<TouchableOpacity
className="flex-1 flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => {
Alert.alert("Select Frequency", "Choose how often this event repeats", [
{
text: "Daily",
onPress: () => setRecurringFrequency("daily"),
},
{
text: "Weekly",
onPress: () => setRecurringFrequency("weekly"),
},
{
text: "Monthly",
onPress: () => setRecurringFrequency("monthly"),
},
{
text: "Yearly",
onPress: () => setRecurringFrequency("yearly"),
},
{ text: "Cancel", style: "cancel" },
]);
}}
>
<Text className="text-base capitalize text-black">{recurringFrequency}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
</View>
{/* Maximum Occurrences */}
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Maximum number of events</Text>
<View className="flex-row items-center gap-2">
<TextInput
className="w-24 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={recurringOccurrences}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
// Don't allow empty or 0 values; fall back to 1 so users can keep editing
if (numericValue === "" || numericValue === "0") {
setRecurringOccurrences("1");
return;
}
const num = parseInt(numericValue);
if (num >= 1) {
setRecurringOccurrences(numericValue);
}
}}
placeholder="12"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<Text className="text-sm text-[#666]">occurrences</Text>
</View>
<Text className="mt-2 text-xs text-[#666]">
The booking will create {recurringOccurrences} events that repeat {recurringFrequency}
</Text>
</View>
</View>
)}
</View>
);
}
-58
View File
@@ -1,58 +0,0 @@
// Utility functions for Event Type Detail
export const formatDuration = (minutes: string) => {
const mins = parseInt(minutes) || 0;
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
};
export const truncateTitle = (text: string, maxLength: number = 20) => {
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
export const formatAppIdToDisplayName = (appId: string): string => {
// Convert appId like "google-meet" to "Google Meet"
return appId
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
export const displayNameToLocationValue = (
displayName: string,
defaultLocations: Array<{ label: string; type: string }>
): {
type: string;
integration?: string;
address?: string;
link?: string;
phone?: string;
public?: boolean;
} | null => {
// First check if it's a default location
const defaultLocation = defaultLocations.find((loc) => loc.label === displayName);
if (defaultLocation) {
// Map internal location types to API location types
switch (defaultLocation.type) {
case "attendeeInPerson":
return { type: "attendeeAddress" };
case "inPerson":
return { type: "address", address: "", public: true };
case "link":
return { type: "link", link: "", public: true };
case "phone":
return { type: "attendeePhone" };
case "userPhone":
return { type: "phone", phone: "", public: true };
default:
return null;
}
}
// Check if it's a conferencing app (formatted display name)
// e.g., "Google Meet", "Zoom", etc.
const appId = displayName.toLowerCase().replace(/\s+/g, "-");
return { type: "integration", integration: appId };
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

@@ -0,0 +1,288 @@
/**
* BookingActionsModal Component
*
* A reusable modal component for booking actions that can be used in both
* the bookings list screen and the booking detail screen.
*/
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { View, Text, TouchableOpacity, Alert } from "react-native";
import { FullScreenModal } from "./FullScreenModal";
import type { Booking } from "../services/calcom";
export interface BookingActionsModalProps {
visible: boolean;
onClose: () => void;
booking: Booking | null;
hasLocationUrl?: boolean;
isUpcoming?: boolean; // When true, disables "after event" actions (View Recordings, Session Details, Mark No-Show)
isPast?: boolean; // When true, disables "edit event" actions (Reschedule, Edit Location, Add Guests) and Cancel Booking
isCancelled?: boolean; // When true, only Report Booking and Mark as No-Show are enabled
isUnconfirmed?: boolean; // When true, disables Reschedule, Edit Location, Add Guests
onReschedule: () => void;
onEditLocation: () => void;
onAddGuests: () => void;
onViewRecordings: () => void;
onMeetingSessionDetails: () => void;
onMarkNoShow: () => void;
onReportBooking: () => void;
onCancelBooking: () => void;
}
// Style constants for easy customization
const ICON_SIZE = 16;
const ICON_COLOR = "#6B7280";
const ICON_COLOR_DANGER = "#800000"; // Maroon
const DISABLED_ICON_COLOR = "#D1D5DB";
const TEXT_CLASS = "text-lg";
const TEXT_COLOR_CLASS = "text-gray-900";
const TEXT_COLOR_DANGER_CLASS = "text-[#800000]"; // Maroon
const DISABLED_TEXT_COLOR_CLASS = "text-gray-300";
export function BookingActionsModal({
visible,
onClose,
booking,
hasLocationUrl = false,
isUpcoming = false,
isPast = false,
isCancelled = false,
isUnconfirmed = false,
onReschedule,
onEditLocation,
onAddGuests,
onViewRecordings,
onMeetingSessionDetails,
onMarkNoShow,
onReportBooking,
onCancelBooking,
}: BookingActionsModalProps) {
if (!booking) return null;
// For cancelled bookings, only Report Booking and Mark as No-Show are enabled
// "After event" actions (except Mark as No-Show) are disabled for upcoming, cancelled, or unconfirmed bookings
const afterEventActionsDisabled = isUpcoming || isCancelled || isUnconfirmed;
// "Edit event" actions (Reschedule, Edit Location, Add Guests) are disabled for past, cancelled, or unconfirmed bookings
const editEventActionsDisabled = isPast || isCancelled || isUnconfirmed;
// Cancel booking is disabled for past or cancelled bookings (but NOT for unconfirmed - user can still cancel/decline)
const cancelBookingDisabled = isPast || isCancelled;
return (
<FullScreenModal visible={visible} animationType="fade" onRequestClose={onClose}>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Actions List */}
<View className="p-2">
{/* Edit event label */}
<View className="px-4 py-1">
<Text className="text-xs font-medium text-gray-500">Edit event</Text>
</View>
{/* Reschedule Booking */}
<TouchableOpacity
onPress={() => {
if (editEventActionsDisabled) return;
onClose();
onReschedule();
}}
disabled={editEventActionsDisabled}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons
name="calendar-outline"
size={ICON_SIZE}
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
/>
<Text
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
>
Reschedule Booking
</Text>
</TouchableOpacity>
{/* Edit Location */}
<TouchableOpacity
onPress={() => {
if (editEventActionsDisabled) return;
onClose();
onEditLocation();
}}
disabled={editEventActionsDisabled}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons
name="location-outline"
size={ICON_SIZE}
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
/>
<Text
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
>
Edit Location
</Text>
</TouchableOpacity>
{/* Add Guests */}
<TouchableOpacity
onPress={() => {
if (editEventActionsDisabled) return;
onClose();
onAddGuests();
}}
disabled={editEventActionsDisabled}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons
name="person-add-outline"
size={ICON_SIZE}
color={editEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
/>
<Text
className={`ml-3 ${TEXT_CLASS} ${editEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
>
Add Guests
</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* After event label */}
<View className="px-4 py-1">
<Text className="text-xs font-medium text-gray-500">After event</Text>
</View>
{/* View Recordings */}
{hasLocationUrl && (
<TouchableOpacity
onPress={() => {
if (afterEventActionsDisabled) return;
onClose();
onViewRecordings();
}}
disabled={afterEventActionsDisabled}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons
name="videocam-outline"
size={ICON_SIZE}
color={afterEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
/>
<Text
className={`ml-3 ${TEXT_CLASS} ${afterEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
>
View Recordings
</Text>
</TouchableOpacity>
)}
{/* Meeting Session Details */}
{hasLocationUrl && (
<TouchableOpacity
onPress={() => {
if (afterEventActionsDisabled) return;
onClose();
onMeetingSessionDetails();
}}
disabled={afterEventActionsDisabled}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons
name="information-circle-outline"
size={ICON_SIZE}
color={afterEventActionsDisabled ? DISABLED_ICON_COLOR : ICON_COLOR}
/>
<Text
className={`ml-3 ${TEXT_CLASS} ${afterEventActionsDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
>
Meeting Session Details
</Text>
</TouchableOpacity>
)}
{/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */}
<TouchableOpacity
onPress={() => {
if (isUpcoming || isUnconfirmed) return;
onClose();
onMarkNoShow();
}}
disabled={isUpcoming || isUnconfirmed}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons
name="eye-off-outline"
size={ICON_SIZE}
color={isUpcoming || isUnconfirmed ? DISABLED_ICON_COLOR : ICON_COLOR}
/>
<Text
className={`ml-3 ${TEXT_CLASS} ${isUpcoming || isUnconfirmed ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_CLASS}`}
>
Mark as No-Show
</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Report Booking */}
<TouchableOpacity
onPress={() => {
onClose();
onReportBooking();
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="flag-outline" size={ICON_SIZE} color={ICON_COLOR_DANGER} />
<Text className={`ml-3 ${TEXT_CLASS} ${TEXT_COLOR_DANGER_CLASS}`}>
Report Booking
</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Cancel Booking */}
<TouchableOpacity
onPress={() => {
if (cancelBookingDisabled) return;
onClose();
onCancelBooking();
}}
disabled={cancelBookingDisabled}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons
name="close-circle-outline"
size={ICON_SIZE}
color={cancelBookingDisabled ? DISABLED_ICON_COLOR : ICON_COLOR_DANGER}
/>
<Text
className={`ml-3 ${TEXT_CLASS} ${cancelBookingDisabled ? DISABLED_TEXT_COLOR_CLASS : TEXT_COLOR_DANGER_CLASS}`}
>
Cancel Event
</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<TouchableOpacity className="w-full rounded-lg bg-gray-100 p-3" onPress={onClose}>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
);
}
@@ -0,0 +1,146 @@
/**
* CacheStatusIndicator Component
*
* A subtle, non-intrusive component that displays cache status information:
* - "Last updated: X minutes ago" when online
* - "Offline - showing cached data" when offline
*
* This component is designed to be placed in screen headers or footers
* to provide users with transparency about data freshness.
*/
import React from "react";
import { View, Text, StyleSheet } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useQueryContext } from "../contexts/QueryContext";
/**
* Props for the CacheStatusIndicator component
*/
interface CacheStatusIndicatorProps {
/** Timestamp when the data was last fetched (from query.dataUpdatedAt) */
dataUpdatedAt?: number;
/** Whether data is currently being fetched */
isFetching?: boolean;
/** Optional custom style */
style?: object;
/** Show compact version (icon only when online) */
compact?: boolean;
}
/**
* Format the time difference in a human-readable way
*/
const formatTimeAgo = (timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
if (diffSeconds < 10) {
return "Just now";
}
if (diffSeconds < 60) {
return `${diffSeconds}s ago`;
}
if (diffMinutes < 60) {
return `${diffMinutes}m ago`;
}
if (diffHours < 24) {
return `${diffHours}h ago`;
}
return "Over a day ago";
};
/**
* CacheStatusIndicator component
*
* @example
* ```tsx
* const { dataUpdatedAt, isFetching } = useBookings();
*
* <CacheStatusIndicator
* dataUpdatedAt={dataUpdatedAt}
* isFetching={isFetching}
* />
* ```
*/
export function CacheStatusIndicator({
dataUpdatedAt,
isFetching = false,
style,
compact = false,
}: CacheStatusIndicatorProps) {
const { isOnline } = useQueryContext();
// Don't show anything if no data has been fetched yet
if (!dataUpdatedAt && !isFetching && isOnline) {
return null;
}
// Offline state
if (!isOnline) {
return (
<View style={[styles.container, styles.offlineContainer, style]}>
<Ionicons name="cloud-offline-outline" size={12} color="#FF9500" />
<Text style={styles.offlineText}>Offline - showing cached data</Text>
</View>
);
}
// Fetching state
if (isFetching) {
return (
<View style={[styles.container, style]}>
<Ionicons name="sync-outline" size={12} color="#8E8E93" />
{!compact && <Text style={styles.text}>Updating...</Text>}
</View>
);
}
// Normal state with last updated time
if (dataUpdatedAt) {
if (compact) {
return (
<View style={[styles.container, style]}>
<Ionicons name="time-outline" size={12} color="#8E8E93" />
</View>
);
}
return (
<View style={[styles.container, style]}>
<Ionicons name="time-outline" size={12} color="#8E8E93" />
<Text style={styles.text}>Updated {formatTimeAgo(dataUpdatedAt)}</Text>
</View>
);
}
return null;
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingVertical: 4,
paddingHorizontal: 8,
},
offlineContainer: {
backgroundColor: "rgba(255, 149, 0, 0.1)",
borderRadius: 4,
},
text: {
fontSize: 11,
color: "#8E8E93",
},
offlineText: {
fontSize: 11,
color: "#FF9500",
fontWeight: "500",
},
});
export default CacheStatusIndicator;
+51
View File
@@ -0,0 +1,51 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
type IoniconName = keyof typeof Ionicons.glyphMap;
interface EmptyScreenProps {
icon: IoniconName;
headline: string;
description: string;
buttonText?: string;
onButtonPress?: () => void;
className?: string;
}
export function EmptyScreen({
icon,
headline,
description,
buttonText,
onButtonPress,
className,
}: EmptyScreenProps) {
return (
<View
className={`flex w-full select-none flex-col items-center justify-center rounded-lg border border-gray-300 bg-white p-7 ${className || ""}`}
>
<View className="h-[72px] w-[72px] items-center justify-center rounded-full border border-gray-300 bg-gray-100">
<Ionicons name={icon} size={40} color="#374151" />
</View>
<View className="mt-6 max-w-[420px] flex-col items-center">
<Text className="text-center text-2xl font-semibold text-gray-900">{headline}</Text>
<Text className="mb-8 mt-3 text-center text-sm font-normal leading-6 text-gray-500">
{description}
</Text>
{buttonText && onButtonPress && (
<TouchableOpacity
className="flex-row items-center justify-center gap-1 rounded-lg bg-gray-900 px-4 py-2.5"
onPress={onButtonPress}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-medium text-white">{buttonText}</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
+75 -51
View File
@@ -7,16 +7,16 @@ import {
TouchableOpacity,
Image,
ActivityIndicator,
Modal,
Alert,
Linking,
Platform,
ActionSheetIOS,
Alert,
} from "react-native";
import * as Clipboard from "expo-clipboard";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, UserProfile } from "../services/calcom";
import { CalComLogo } from "./CalComLogo";
import { FullScreenModal } from "./FullScreenModal";
import { openInAppBrowser } from "../utils/browser";
export function Header() {
const router = useRouter();
@@ -40,14 +40,41 @@ export function Header() {
}
};
// Build public page URL
const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null;
const handleViewPublicPage = () => {
if (publicPageUrl) {
openInAppBrowser(publicPageUrl, "Public page");
}
};
const handleCopyPublicPageLink = async () => {
if (!publicPageUrl) return;
try {
await Clipboard.setStringAsync(publicPageUrl);
Alert.alert("Link Copied!", "Your public page link has been copied to clipboard.");
} catch (error) {
console.error("Failed to copy public page link:", error);
Alert.alert("Error", "Failed to copy link. Please try again.");
}
};
const handleProfile = () => {
if (Platform.OS === "ios") {
const options = ["Cancel", "My Profile", "My Settings", "Out of Office", "Help", "Sign Out"];
const options = [
"Cancel",
"My Profile",
"My Settings",
"Out of Office",
"View public page",
"Copy public page link",
"Help",
];
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex: 6, // Sign Out
cancelButtonIndex: 0,
title: userProfile?.name || "Profile Menu",
},
@@ -62,11 +89,14 @@ export function Header() {
case 3: // Out of Office
handleMenuOption("outOfOffice");
break;
case 4: // Support
handleMenuOption("help");
case 4: // View public page
handleViewPublicPage();
break;
case 5: // Sign Out
handleMenuOption("signOut");
case 5: // Copy public page link
handleCopyPublicPageLink();
break;
case 6: // Help
handleMenuOption("help");
break;
}
}
@@ -76,55 +106,28 @@ export function Header() {
}
};
const openExternalLink = async (url: string, fallbackMessage: string) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`);
}
} catch (error) {
console.error(`Failed to open ${url}:`, error);
Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`);
}
};
const handleMenuOption = (option: string) => {
if (Platform.OS !== "ios") {
setShowProfileModal(false);
}
switch (option) {
case "profile":
openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page");
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page");
break;
case "settings":
openExternalLink("https://app.cal.com/settings/my-account", "Settings page");
openInAppBrowser("https://app.cal.com/settings/my-account", "Settings page");
break;
case "outOfOffice":
openExternalLink(
openInAppBrowser(
"https://app.cal.com/settings/my-account/out-of-office",
"Out of Office page"
);
break;
case "roadmap":
openExternalLink("https://cal.com/roadmap", "Roadmap");
openInAppBrowser("https://cal.com/roadmap", "Roadmap");
break;
case "help":
openExternalLink("https://cal.com/help", "Help page");
break;
case "signOut":
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: () => {
// TODO: Implement sign out
console.log("Sign Out pressed");
},
},
]);
openInAppBrowser("https://cal.com/help", "Help page");
break;
}
};
@@ -250,6 +253,37 @@ export function Header() {
<View 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")}
@@ -271,16 +305,6 @@ export function Header() {
</View>
<Ionicons name="open-outline" size={16} color="#6B7280" />
</TouchableOpacity>
<View className="mx-4 my-2 h-px bg-gray-200" />
<TouchableOpacity
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
onPress={() => handleMenuOption("signOut")}
>
<Ionicons name="log-out-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Sign Out</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
+71
View File
@@ -0,0 +1,71 @@
/**
* LoadingSpinner Component
*
* A stylish loading spinner with iOS glass effect support when available.
* Falls back to a nice styled container on other platforms.
*/
import React from "react";
import { View, ActivityIndicator, Platform, StyleSheet } from "react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
interface LoadingSpinnerProps {
/** Size of the spinner - defaults to large */
size?: "small" | "large";
/** Color of the spinner - defaults to system color */
color?: string;
/** Whether to show the container background */
showBackground?: boolean;
}
export function LoadingSpinner({
size = "large",
color,
showBackground = true,
}: LoadingSpinnerProps) {
const supportsGlass = isLiquidGlassAvailable();
// Use glass effect on supported iOS devices
if (supportsGlass && Platform.OS === "ios") {
return (
<GlassView style={styles.glassContainer} glassEffectStyle="regular">
<ActivityIndicator size={size} color={color || "#000000"} />
</GlassView>
);
}
// Styled container for other platforms
if (showBackground) {
return (
<View style={styles.styledContainer}>
<ActivityIndicator size={size} color={color || "#000000"} />
</View>
);
}
// Simple spinner without background
return <ActivityIndicator size={size} color={color || "#000000"} />;
}
const styles = StyleSheet.create({
glassContainer: {
alignItems: "center",
justifyContent: "center",
padding: 24,
borderRadius: 20,
},
styledContainer: {
alignItems: "center",
justifyContent: "center",
padding: 24,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.9)",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
});
export default LoadingSpinner;
+274
View File
@@ -0,0 +1,274 @@
/**
* LocationsList Component
* Reusable component for displaying and managing multiple event type locations
*/
import React, { useState } from "react";
import {
View,
Text,
TouchableOpacity,
TextInput,
Modal,
ScrollView,
Platform,
ActionSheetIOS,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { SvgImage } from "./SvgImage";
import { LocationItem, LocationOptionGroup } from "../types/locations";
import {
locationRequiresInput,
getLocationInputPlaceholder,
getLocationInputLabel,
createLocationItemFromOption,
} from "../utils/locationHelpers";
interface LocationsListProps {
/** Array of current locations */
locations: LocationItem[];
/** Callback when a location is added */
onAdd: (location: LocationItem) => void;
/** Callback when a location is removed */
onRemove: (locationId: string) => void;
/** Callback when a location is updated (for input fields) */
onUpdate: (locationId: string, updates: Partial<LocationItem>) => void;
/** Available location options grouped by category */
locationOptions: LocationOptionGroup[];
/** Whether the component is disabled */
disabled?: boolean;
/** Whether locations are loading */
loading?: boolean;
}
function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string): boolean {
return locations.some((loc) => {
if (loc.type === "integration" && optionValue.startsWith("integrations:")) {
return loc.integration === optionValue.replace("integrations:", "");
}
if (["address", "link", "phone"].includes(loc.type)) {
return false;
}
return loc.type === optionValue;
});
}
export const LocationsList: React.FC<LocationsListProps> = ({
locations,
onAdd,
onRemove,
onUpdate,
locationOptions,
disabled = false,
loading = false,
}) => {
const [showAddModal, setShowAddModal] = useState(false);
const handleAddLocation = () => {
if (Platform.OS === "ios") {
const allOptions: Array<{ label: string; value: string }> = [];
locationOptions.forEach((group) => {
group.options.forEach((option) => {
if (!isLocationAlreadyAdded(locations, option.value)) {
allOptions.push({ label: option.label, value: option.value });
}
});
});
const options = [...allOptions.map((o) => o.label), "Cancel"];
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: options.length - 1,
title: "Add Location",
},
(buttonIndex) => {
if (buttonIndex !== options.length - 1 && buttonIndex < allOptions.length) {
const selected = allOptions[buttonIndex];
const newLocation = createLocationItemFromOption(selected.value, selected.label);
onAdd(newLocation);
}
}
);
} else {
setShowAddModal(true);
}
};
const handleSelectOption = (optionValue: string, optionLabel: string) => {
const newLocation = createLocationItemFromOption(optionValue, optionLabel);
onAdd(newLocation);
setShowAddModal(false);
};
const renderLocationIcon = (location: LocationItem) => {
if (location.iconUrl) {
return <SvgImage uri={location.iconUrl} width={20} height={20} style={{ marginRight: 12 }} />;
}
return (
<View style={{ marginRight: 12 }}>
<Ionicons name="location-outline" size={20} color="#6B7280" />
</View>
);
};
const renderLocationInput = (location: LocationItem) => {
if (!locationRequiresInput(location.type)) {
return null;
}
const placeholder = getLocationInputPlaceholder(location.type);
const label = getLocationInputLabel(location.type);
let value = "";
let fieldKey: "address" | "link" | "phone" = "address";
switch (location.type) {
case "address":
value = location.address || "";
fieldKey = "address";
break;
case "link":
value = location.link || "";
fieldKey = "link";
break;
case "phone":
value = location.phone || "";
fieldKey = "phone";
break;
}
return (
<View className="mt-2">
<Text className="mb-1 text-xs text-gray-500">{label}</Text>
<TextInput
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
placeholder={placeholder}
value={value}
onChangeText={(text) => onUpdate(location.id, { [fieldKey]: text })}
editable={!disabled}
keyboardType={location.type === "phone" ? "phone-pad" : "default"}
autoCapitalize={location.type === "link" ? "none" : "sentences"}
/>
</View>
);
};
return (
<View>
{/* Locations List */}
{locations.length > 0 ? (
<View className="mb-3 space-y-2">
{locations.map((location, index) => (
<View key={location.id} className="rounded-lg border border-gray-200 bg-white p-3">
<View className="flex-row items-center justify-between">
<View className="flex-1 flex-row items-center">
{renderLocationIcon(location)}
<Text className="flex-1 text-base text-gray-900" numberOfLines={1}>
{location.displayName}
</Text>
</View>
{!disabled && (
<TouchableOpacity
onPress={() => onRemove(location.id)}
className="ml-2 p-1"
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close-circle" size={20} color="#9CA3AF" />
</TouchableOpacity>
)}
</View>
{renderLocationInput(location)}
</View>
))}
</View>
) : (
<View className="mb-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<Text className="text-center text-sm text-gray-500">No locations added yet</Text>
</View>
)}
{/* Add Location Button */}
<TouchableOpacity
onPress={handleAddLocation}
disabled={disabled || loading}
className={`flex-row items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 ${
disabled ? "opacity-50" : "active:bg-gray-100"
}`}
>
{loading ? (
<Text className="text-sm text-gray-500">Loading options...</Text>
) : (
<>
<Ionicons name="add-circle-outline" size={20} color="#6B7280" />
<Text className="ml-2 text-sm font-medium text-gray-600">Add Location</Text>
</>
)}
</TouchableOpacity>
{/* Add Location Modal (for non-iOS) */}
<Modal
visible={showAddModal}
transparent
animationType="slide"
onRequestClose={() => setShowAddModal(false)}
>
<View className="flex-1 justify-end bg-black/50">
<View className="max-h-[70%] rounded-t-3xl bg-white">
{/* Header */}
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
<Text className="text-lg font-semibold text-gray-900">Add Location</Text>
<TouchableOpacity onPress={() => setShowAddModal(false)} className="p-1">
<Ionicons name="close" size={24} color="#6B7280" />
</TouchableOpacity>
</View>
{/* Options List */}
<ScrollView className="px-4 py-2">
{locationOptions.map((group, groupIndex) => (
<View key={group.category} className={groupIndex > 0 ? "mt-4" : ""}>
<Text className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
{group.category}
</Text>
{group.options.map((option) => {
const alreadyAdded = isLocationAlreadyAdded(locations, option.value);
return (
<TouchableOpacity
key={option.value}
onPress={() => handleSelectOption(option.value, option.label)}
disabled={alreadyAdded}
className={`flex-row items-center rounded-lg px-2 py-3 ${
alreadyAdded ? "opacity-40" : "active:bg-gray-100"
}`}
>
{option.iconUrl ? (
<SvgImage
uri={option.iconUrl}
width={24}
height={24}
style={{ marginRight: 12 }}
/>
) : (
<View style={{ marginRight: 12 }}>
<Ionicons name="location-outline" size={24} color="#6B7280" />
</View>
)}
<Text className="flex-1 text-base text-gray-900">{option.label}</Text>
{alreadyAdded && <Ionicons name="checkmark" size={20} color="#10B981" />}
</TouchableOpacity>
);
})}
</View>
))}
{/* Bottom padding for safe area */}
<View className="h-8" />
</ScrollView>
</View>
</View>
</Modal>
</View>
);
};
+52 -23
View File
@@ -1,46 +1,75 @@
import React from "react";
import { View, Text, TouchableOpacity, Alert, ActivityIndicator } from "react-native";
import { View, Text, TouchableOpacity } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAuth } from "../contexts/AuthContext";
import { CalComLogo } from "./CalComLogo";
import { showErrorAlert } from "../utils/alerts";
import { openInAppBrowser } from "../utils/browser";
export function LoginScreen() {
const { loginWithOAuth, loading } = useAuth();
const insets = useSafeAreaInsets();
const handleOAuthLogin = async () => {
try {
await loginWithOAuth();
} catch (error) {
console.error("OAuth login error:", error);
Alert.alert(
showErrorAlert(
"Login Failed",
error instanceof Error
? error.message
: "Failed to login with OAuth. Please check your configuration and try again.",
[{ text: "OK" }]
: "Failed to login with OAuth. Please check your configuration and try again."
);
}
};
const handleSignUp = async () => {
await openInAppBrowser("https://app.cal.com/signup", "Sign up page");
};
return (
<View className="flex-1 justify-center bg-white px-6">
<View className="mb-8">
<Text className="mb-2 text-center text-3xl font-bold text-gray-900">
Welcome to Cal.com Companion
</Text>
<Text className="text-center text-gray-600">
Sign in to manage your bookings and event types
</Text>
<View className="flex-1 bg-white">
{/* Logo centered in the middle */}
<View className="flex-1 items-center justify-center">
<CalComLogo width={240} height={54} color="#111827" />
</View>
{/* Bottom section with button */}
<View className="px-6" style={{ paddingBottom: insets.bottom + 28 }}>
{/* Primary CTA button */}
<TouchableOpacity
onPress={handleOAuthLogin}
disabled={loading}
className="flex-row items-center justify-center rounded-2xl py-[18px]"
style={{
backgroundColor: loading ? "#9CA3AF" : "#111827",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: loading ? 0 : 0.2,
shadowRadius: 12,
elevation: loading ? 0 : 6,
}}
activeOpacity={0.9}
>
<Text className="text-[17px] font-semibold text-white">Continue with Cal.com</Text>
</TouchableOpacity>
{/* Sign up link */}
<TouchableOpacity
onPress={handleSignUp}
className="mt-3 items-center justify-center py-1"
style={{ cursor: "pointer" } as any}
activeOpacity={0.7}
>
<View>
<Text className="text-[15px] text-gray-500">
Don't have an account? <Text className="font-semibold text-gray-900">Sign up</Text>
</Text>
<View className="h-px bg-gray-400" style={{ marginTop: 2 }} />
</View>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handleOAuthLogin}
disabled={loading}
className="mb-4 flex-row items-center justify-center rounded-lg bg-gray-900 px-6 py-4"
style={{ opacity: loading ? 0.7 : 1 }}
>
{loading ? <ActivityIndicator size="small" color="white" className="mr-2" /> : null}
<Text className="text-lg font-semibold text-white">
{loading ? "Signing in..." : "Sign in with Cal.com"}
</Text>
</TouchableOpacity>
</View>
);
}
@@ -0,0 +1,110 @@
/**
* NetworkStatusBanner Component
*
* Shows a minimal, classy popup when the device is offline.
* - Shows popup when going offline
* - Auto-dismisses when internet comes back
* - Shows again on next disconnect
*/
import React, { useEffect, useState, useRef } from "react";
import { View, Text, TouchableOpacity, Modal, Animated } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
export function NetworkStatusBanner() {
const [showModal, setShowModal] = useState(false);
const fadeAnim = useRef(new Animated.Value(0)).current;
// Simple refs to track state
const previousOfflineRef = useRef<boolean | null>(null);
const userDismissedRef = useRef(false);
const checkIfOffline = (state: NetInfoState): boolean => {
if (state.isConnected === false) return true;
if (state.isInternetReachable === false) return true;
return false;
};
useEffect(() => {
const handleNetworkChange = (state: NetInfoState) => {
const currentlyOffline = checkIfOffline(state);
const wasOffline = previousOfflineRef.current;
// Transition: Online → Offline (only show if user hasn't dismissed)
if (currentlyOffline && wasOffline === false && !userDismissedRef.current) {
setShowModal(true);
}
// Transition: Offline → Online
if (!currentlyOffline && wasOffline === true) {
setShowModal(false); // Auto-dismiss
userDismissedRef.current = false; // Reset for next offline event
}
previousOfflineRef.current = currentlyOffline;
};
// Get initial state
NetInfo.fetch().then((state) => {
const offline = checkIfOffline(state);
previousOfflineRef.current = offline;
if (offline) {
setShowModal(true);
}
});
// Listen for changes
const unsubscribe = NetInfo.addEventListener(handleNetworkChange);
return () => unsubscribe();
}, []);
useEffect(() => {
if (showModal) {
fadeAnim.setValue(0);
Animated.timing(fadeAnim, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}).start();
}
}, [showModal, fadeAnim]);
const handleDismiss = () => {
userDismissedRef.current = true;
Animated.timing(fadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}).start(() => {
setShowModal(false);
});
};
if (!showModal) return null;
return (
<Modal transparent visible={showModal} animationType="none" onRequestClose={handleDismiss}>
<Animated.View
style={{ opacity: fadeAnim }}
className="flex-1 items-center justify-center bg-black/40 px-8"
>
<View className="w-full max-w-xs items-center rounded-2xl bg-white px-6 py-8">
<Ionicons name="cloud-offline-outline" size={36} color="#292929" />
<Text className="mt-4 text-lg font-semibold text-gray-900">You're offline</Text>
<Text className="mt-2 text-center text-sm leading-5 text-gray-500">
The internet left the chat. Don't panic! You can still browse around.
</Text>
<TouchableOpacity
onPress={handleDismiss}
className="mt-6 rounded-full bg-gray-900 px-8 py-3 active:bg-gray-700"
>
<Text className="text-sm font-medium text-white">Cool, got it</Text>
</TouchableOpacity>
</View>
</Animated.View>
</Modal>
);
}
export default NetworkStatusBanner;
@@ -0,0 +1,514 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { openInAppBrowser } from "../../../utils/browser";
interface ConfigureOnWebCardProps {
title: string;
description: string;
eventTypeId: string;
browserTitle: string;
}
function ConfigureOnWebCard({
title,
description,
eventTypeId,
browserTitle,
}: ConfigureOnWebCardProps) {
return (
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-medium text-[#333]">{title}</Text>
<Text className="mb-3 text-sm text-[#666]">{description}</Text>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 md:px-4"
onPress={() => {
if (eventTypeId && eventTypeId !== "new") {
openInAppBrowser(
`https://app.cal.com/event-types/${eventTypeId}?tabName=advanced`,
browserTitle
);
} else {
Alert.alert("Info", "Save the event type first to configure this setting.");
}
}}
>
<Ionicons name="settings-outline" size={20} color="#666" />
<Text className="ml-2 text-base text-[#666]">Configure on Web</Text>
</TouchableOpacity>
</View>
);
}
interface AdvancedTabProps {
requiresConfirmation: boolean;
setRequiresConfirmation: (value: boolean) => void;
autoTranslate: boolean;
setAutoTranslate: (value: boolean) => void;
requiresBookerEmailVerification: boolean;
setRequiresBookerEmailVerification: (value: boolean) => void;
hideCalendarNotes: boolean;
setHideCalendarNotes: (value: boolean) => void;
hideCalendarEventDetails: boolean;
setHideCalendarEventDetails: (value: boolean) => void;
hideOrganizerEmail: boolean;
setHideOrganizerEmail: (value: boolean) => void;
lockTimezone: boolean;
setLockTimezone: (value: boolean) => void;
lockedTimezone: string;
setLockedTimezone: (value: string) => void;
allowReschedulingPastEvents: boolean;
setAllowReschedulingPastEvents: (value: boolean) => void;
allowBookingThroughRescheduleLink: boolean;
setAllowBookingThroughRescheduleLink: (value: boolean) => void;
successRedirectUrl: string;
setSuccessRedirectUrl: (value: string) => void;
forwardParamsSuccessRedirect: boolean;
setForwardParamsSuccessRedirect: (value: boolean) => void;
customReplyToEmail: string;
setCustomReplyToEmail: (value: string) => void;
eventTypeColorLight: string;
setEventTypeColorLight: (value: string) => void;
eventTypeColorDark: string;
setEventTypeColorDark: (value: string) => void;
seatsEnabled: boolean;
setSeatsEnabled: (value: boolean) => void;
seatsPerTimeSlot: string;
setSeatsPerTimeSlot: (value: string) => void;
showAttendeeInfo: boolean;
setShowAttendeeInfo: (value: boolean) => void;
showAvailabilityCount: boolean;
setShowAvailabilityCount: (value: boolean) => void;
eventTypeId: string;
}
export function AdvancedTab(props: AdvancedTabProps) {
return (
<View className="gap-3">
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Requires confirmation</Text>
<Text className="text-sm text-[#666]">
The booking needs to be manually confirmed before it is pushed to your calendar and a
confirmation is sent.
</Text>
</View>
<Switch
value={props.requiresConfirmation}
onValueChange={props.setRequiresConfirmation}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
<ConfigureOnWebCard
title="Disable Cancelling"
description="Guests and Organizer can no longer cancel the event with calendar invite or email."
eventTypeId={props.eventTypeId}
browserTitle="Disable Cancelling"
/>
<ConfigureOnWebCard
title="Disable Rescheduling"
description="Guests and Organizer can no longer reschedule the event with calendar invite or email."
eventTypeId={props.eventTypeId}
browserTitle="Disable Rescheduling"
/>
<ConfigureOnWebCard
title="Send Cal Video Transcription Emails"
description="Send emails with the transcription of the Cal Video after the meeting ends. (Requires a paid plan)"
eventTypeId={props.eventTypeId}
browserTitle="Cal Video Transcription"
/>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Auto translate title and description
</Text>
<Text className="text-sm text-[#666]">
Automatically translate titles and descriptions to the visitor's browser language
using AI.
</Text>
</View>
<Switch
value={props.autoTranslate}
onValueChange={props.setAutoTranslate}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
<ConfigureOnWebCard
title="Interface Language"
description="Set your preferred language for the booking interface."
eventTypeId={props.eventTypeId}
browserTitle="Interface Language"
/>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Requires booker email verification
</Text>
<Text className="text-sm text-[#666]">
To ensure booker's email verification before scheduling events.
</Text>
</View>
<Switch
value={props.requiresBookerEmailVerification}
onValueChange={props.setRequiresBookerEmailVerification}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Hide notes in calendar</Text>
<Text className="text-sm text-[#666]">
For privacy reasons, additional inputs and notes will be hidden in the calendar entry.
They will still be sent to your email.
</Text>
</View>
<Switch
value={props.hideCalendarNotes}
onValueChange={props.setHideCalendarNotes}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Hide calendar event details on shared calendars
</Text>
<Text className="text-sm text-[#666]">
When a calendar is shared, events are visible to readers but their details are hidden
from those without write access.
</Text>
</View>
<Switch
value={props.hideCalendarEventDetails}
onValueChange={props.setHideCalendarEventDetails}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-medium text-[#333]">Redirect on booking</Text>
<Text className="mb-3 text-sm text-[#666]">
Redirect to a custom URL after a successful booking.
</Text>
<TextInput
className="mb-3 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.successRedirectUrl}
onChangeText={props.setSuccessRedirectUrl}
placeholder="https://example.com/thank-you"
placeholderTextColor="#8E8E93"
keyboardType="url"
autoCapitalize="none"
/>
<View className="flex-row items-center justify-between">
<Text className="flex-1 text-sm text-[#333]">
Forward parameters such as ?email=...&name=...
</Text>
<Switch
value={props.forwardParamsSuccessRedirect}
onValueChange={props.setForwardParamsSuccessRedirect}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
{props.successRedirectUrl ? (
<Text className="mt-2 text-xs text-[#FF9500]">
Adding a redirect will disable the success page. Make sure to mention "Booking
Confirmed" on your custom success page.
</Text>
) : null}
</View>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-medium text-[#333]">Private Links</Text>
<Text className="mb-3 text-sm text-[#666]">
Generate private URLs without exposing the username, with configurable expiry and usage
limits.
</Text>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 md:px-4"
onPress={() => {
if (props.eventTypeId && props.eventTypeId !== "new") {
openInAppBrowser(
`https://app.cal.com/event-types/${props.eventTypeId}?tabName=advanced`,
"Private Links"
);
} else {
Alert.alert("Info", "Save the event type first to manage private links.");
}
}}
>
<Ionicons name="link-outline" size={20} color="#666" />
<Text className="ml-2 text-base text-[#666]">Manage Private Links</Text>
</TouchableOpacity>
</View>
<View className="rounded-2xl border border-[#E5E5EA] bg-white">
<View className="flex-row items-start justify-between p-5">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Offer seats</Text>
<Text className="text-sm text-[#666]">
Offer seats for booking. This automatically disables guest & opt-in bookings.{" "}
<Text
className="text-sm text-[#007AFF]"
onPress={() =>
openInAppBrowser("https://cal.com/help/event-types/offer-seats", "Learn more")
}
>
Learn more
</Text>
</Text>
</View>
<Switch
value={props.seatsEnabled}
onValueChange={props.setSeatsEnabled}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
{/* Seats Configuration - shown when enabled */}
{props.seatsEnabled && (
<View className="gap-4 border-t border-[#E5E5EA] p-5">
{/* Number of seats per booking */}
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">
Number of seats per booking
</Text>
<View className="flex-row items-center">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={props.seatsPerTimeSlot}
onChangeText={props.setSeatsPerTimeSlot}
placeholder="2"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<Text className="ml-3 text-base text-[#666]">seats</Text>
</View>
</View>
{/* Share attendee information between guests - Checkbox style */}
<TouchableOpacity
className="flex-row items-center"
onPress={() => props.setShowAttendeeInfo(!props.showAttendeeInfo)}
activeOpacity={0.7}
>
<View
className={`mr-3 h-5 w-5 items-center justify-center rounded border ${
props.showAttendeeInfo
? "border-[#111827] bg-[#111827]"
: "border-[#D1D5DB] bg-white"
}`}
>
{props.showAttendeeInfo && <Ionicons name="checkmark" size={14} color="#fff" />}
</View>
<Text className="text-sm text-[#333]">Share attendee information between guests</Text>
</TouchableOpacity>
{/* Show the number of available seats - Checkbox style */}
<TouchableOpacity
className="flex-row items-center"
onPress={() => props.setShowAvailabilityCount(!props.showAvailabilityCount)}
activeOpacity={0.7}
>
<View
className={`mr-3 h-5 w-5 items-center justify-center rounded border ${
props.showAvailabilityCount
? "border-[#111827] bg-[#111827]"
: "border-[#D1D5DB] bg-white"
}`}
>
{props.showAvailabilityCount && (
<Ionicons name="checkmark" size={14} color="#fff" />
)}
</View>
<Text className="text-sm text-[#333]">Show the number of available seats</Text>
</TouchableOpacity>
</View>
)}
</View>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Hide organizer's email</Text>
<Text className="text-sm text-[#666]">
Hide organizer's email address from the booking screen, email notifications, and
calendar events.
</Text>
</View>
<Switch
value={props.hideOrganizerEmail}
onValueChange={props.setHideOrganizerEmail}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
<View className="rounded-2xl border border-[#E5E5EA] bg-white">
<View className="flex-row items-start justify-between p-5">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Lock timezone on booking page
</Text>
<Text className="text-sm text-[#666]">
To lock the timezone on booking page, useful for in-person events.{" "}
<Text
className="text-sm text-[#007AFF]"
onPress={() =>
openInAppBrowser("https://cal.com/help/event-types/lock-timezone", "Learn more")
}
>
Learn more
</Text>
</Text>
</View>
<Switch
value={props.lockTimezone}
onValueChange={props.setLockTimezone}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
{/* Timezone selector - shown when enabled */}
{props.lockTimezone && (
<View className="border-t border-[#E5E5EA] p-5">
<Text className="mb-2 text-sm font-medium text-[#333]">Timezone</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => {
Alert.alert(
"Select Timezone",
"To change the timezone, please use the Cal.com website for the full timezone selector.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Open Website",
onPress: () =>
openInAppBrowser("https://app.cal.com/event-types", "Cal.com Event Types"),
},
]
);
}}
>
<Text className="text-base text-[#333]">
{props.lockedTimezone || "Europe/London"}
</Text>
<Ionicons name="chevron-down" size={20} color="#666" />
</TouchableOpacity>
</View>
)}
</View>
<ConfigureOnWebCard
title="Allow rescheduling past events"
description="Enabling this option allows for past events to be rescheduled."
eventTypeId={props.eventTypeId}
browserTitle="Allow Rescheduling Past Events"
/>
<ConfigureOnWebCard
title="Allow booking through reschedule link"
description="When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking."
eventTypeId={props.eventTypeId}
browserTitle="Allow Booking Through Reschedule Link"
/>
<ConfigureOnWebCard
title="Custom 'Reply-To' email"
description="Use a different email address as the replyTo for confirmation emails instead of the organizer's email."
eventTypeId={props.eventTypeId}
browserTitle="Custom Reply-To Email"
/>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-medium text-[#333]">Event type color</Text>
<Text className="mb-3 text-sm text-[#666]">
This is only used for event type & booking differentiation within the app. It is not
displayed to bookers.
</Text>
<View className="gap-3">
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">
Event Type Color (Light Theme)
</Text>
<View className="flex-row items-center gap-3">
<View
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
style={{
backgroundColor: props.eventTypeColorLight.startsWith("#")
? props.eventTypeColorLight
: `#${props.eventTypeColorLight}`,
}}
/>
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTypeColorLight}
onChangeText={props.setEventTypeColorLight}
placeholder="292929"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
/>
</View>
</View>
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">
Event Type Color (Dark Theme)
</Text>
<View className="flex-row items-center gap-3">
<View
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
style={{
backgroundColor: props.eventTypeColorDark.startsWith("#")
? props.eventTypeColorDark
: `#${props.eventTypeColorDark}`,
}}
/>
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTypeColorDark}
onChangeText={props.setEventTypeColorDark}
placeholder="fafafa"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
/>
</View>
</View>
</View>
</View>
<ConfigureOnWebCard
title="Optimized slots"
description="Arrange time slots to optimize availability."
eventTypeId={props.eventTypeId}
browserTitle="Optimized Slots"
/>
</View>
);
}
@@ -1,8 +1,9 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { SvgImage } from "../../../components/SvgImage";
import { defaultLocations, DefaultLocationType } from "../../../utils/defaultLocations";
import { LocationsList } from "../../../components/LocationsList";
import { LocationItem, LocationOptionGroup } from "../../../types/locations";
import { slugify } from "../../../utils/slugify";
interface BasicsTabProps {
@@ -25,17 +26,13 @@ interface BasicsTabProps {
defaultDuration: string;
setShowDefaultDurationDropdown: (show: boolean) => void;
// Location
selectedLocation: string;
setShowLocationDropdown: (show: boolean) => void;
// Multiple locations support
locations: LocationItem[];
onAddLocation: (location: LocationItem) => void;
onRemoveLocation: (locationId: string) => void;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
locationOptions: LocationOptionGroup[];
conferencingLoading: boolean;
getSelectedLocationIconUrl: () => string | null;
locationAddress: string;
setLocationAddress: (value: string) => void;
locationLink: string;
setLocationLink: (value: string) => void;
locationPhone: string;
setLocationPhone: (value: string) => void;
}
export function BasicsTab(props: BasicsTabProps) {
@@ -155,102 +152,15 @@ export function BasicsTab(props: BasicsTabProps) {
{/* Location Card */}
<View className="rounded-2xl bg-white p-5">
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Location</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowLocationDropdown(true)}
disabled={props.conferencingLoading}
>
<View className="flex-1 flex-row items-center">
{!props.conferencingLoading &&
props.selectedLocation &&
props.getSelectedLocationIconUrl() && (
<SvgImage
uri={props.getSelectedLocationIconUrl()!}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
<Text className="text-base text-black">
{props.conferencingLoading
? "Loading locations..."
: props.selectedLocation || "Select location"}
</Text>
</View>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
{/* Location Input Fields - shown conditionally based on selected location type */}
{(() => {
const currentLocation = defaultLocations.find(
(loc) => loc.label === props.selectedLocation
);
if (!currentLocation || !currentLocation.organizerInputType) {
return null;
}
if (currentLocation.organizerInputType === "text") {
// Text input for address or link
const isAddress = currentLocation.type === "inPerson";
const isLink = currentLocation.type === "link";
return (
<View className="mt-3">
<Text className="mb-1.5 text-sm font-medium text-[#333]">
{currentLocation.organizerInputLabel ||
(isAddress ? "Address" : "Meeting Link")}
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 text-base text-[#333] md:px-4"
placeholder={currentLocation.organizerInputPlaceholder || ""}
value={isAddress ? props.locationAddress : props.locationLink}
onChangeText={(text) => {
if (isAddress) {
props.setLocationAddress(text);
} else {
props.setLocationLink(text);
}
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType={isLink ? "url" : "default"}
/>
{currentLocation.messageForOrganizer && (
<Text className="mt-2 text-xs text-[#666]">
{currentLocation.messageForOrganizer}
</Text>
)}
</View>
);
} else if (currentLocation.organizerInputType === "phone") {
// Phone input
return (
<View className="mt-3">
<Text className="mb-1.5 text-sm font-medium text-[#333]">
{currentLocation.organizerInputLabel || "Phone Number"}
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 text-base text-[#333] md:px-4"
placeholder={currentLocation.organizerInputPlaceholder || "Enter phone number"}
value={props.locationPhone}
onChangeText={props.setLocationPhone}
autoCapitalize="none"
autoCorrect={false}
keyboardType="phone-pad"
/>
{currentLocation.messageForOrganizer && (
<Text className="mt-2 text-xs text-[#666]">
{currentLocation.messageForOrganizer}
</Text>
)}
</View>
);
}
return null;
})()}
</View>
<Text className="mb-3 text-base font-semibold text-[#333]">Locations</Text>
<LocationsList
locations={props.locations}
onAdd={props.onAddLocation}
onRemove={props.onRemoveLocation}
onUpdate={props.onUpdateLocation}
locationOptions={props.locationOptions}
loading={props.conferencingLoading}
/>
</View>
</View>
);
@@ -1,13 +1,6 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch, Animated } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import {
bufferTimeOptions,
timeUnitOptions,
frequencyUnitOptions,
durationUnitOptions,
slotIntervalOptions,
} from "../constants";
interface FrequencyLimit {
id: number;
@@ -48,6 +41,10 @@ interface LimitsTabProps {
removeFrequencyLimit: (id: number) => void;
addFrequencyLimit: () => void;
// Only show first slot
onlyShowFirstAvailableSlot: boolean;
setOnlyShowFirstAvailableSlot: (value: boolean) => void;
// Total duration
limitTotalDuration: boolean;
toggleTotalDuration: (value: boolean) => void;
@@ -58,15 +55,13 @@ interface LimitsTabProps {
removeDurationLimit: (id: number) => void;
addDurationLimit: () => void;
// Only show first slot
onlyShowFirstAvailableSlot: boolean;
setOnlyShowFirstAvailableSlot: (value: boolean) => void;
// Max active bookings
maxActiveBookingsPerBooker: boolean;
setMaxActiveBookingsPerBooker: (value: boolean) => void;
maxActiveBookingsValue: string;
setMaxActiveBookingsValue: (value: string) => void;
offerReschedule: boolean;
setOfferReschedule: (value: boolean) => void;
// Future bookings
limitFutureBookings: boolean;
@@ -159,7 +154,7 @@ export function LimitsTab(props: LimitsTabProps) {
</View>
</View>
{/* Booking Frequency Limit Card */}
{/* 1. Booking Frequency Limit Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-4 flex-1">
@@ -190,7 +185,7 @@ export function LimitsTab(props: LimitsTabProps) {
>
{props.limitBookingFrequency && (
<>
{props.frequencyLimits.map((limit, index) => (
{props.frequencyLimits.map((limit) => (
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
@@ -218,7 +213,7 @@ export function LimitsTab(props: LimitsTabProps) {
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
onPress={() => props.removeFrequencyLimit(limit.id)}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
<Ionicons name="trash-outline" size={20} color="#800000" />
</TouchableOpacity>
)}
</View>
@@ -235,7 +230,28 @@ export function LimitsTab(props: LimitsTabProps) {
</Animated.View>
</View>
{/* Total Booking Duration Limit Card */}
{/* 2. Only Show First Available Slot Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Only show the first slot of each day as available
</Text>
<Text className="text-sm leading-5 text-[#666]">
This will limit your availability for this event type to one slot per day, scheduled
at the earliest available time.
</Text>
</View>
<Switch
value={props.onlyShowFirstAvailableSlot}
onValueChange={props.setOnlyShowFirstAvailableSlot}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* 3. Total Booking Duration Limit Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-4 flex-1">
@@ -268,7 +284,7 @@ export function LimitsTab(props: LimitsTabProps) {
>
{props.limitTotalDuration && (
<>
{props.durationLimits.map((limit, index) => (
{props.durationLimits.map((limit) => (
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
<View className="flex-row items-center gap-3">
<TextInput
@@ -299,7 +315,7 @@ export function LimitsTab(props: LimitsTabProps) {
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
onPress={() => props.removeDurationLimit(limit.id)}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
<Ionicons name="trash-outline" size={20} color="#800000" />
</TouchableOpacity>
)}
</View>
@@ -316,30 +332,9 @@ export function LimitsTab(props: LimitsTabProps) {
</Animated.View>
</View>
{/* Only Show First Available Slot Card */}
{/* 4. Max Active Bookings Per Booker Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Only show the first slot of each day as available
</Text>
<Text className="text-sm leading-5 text-[#666]">
This will limit your availability for this event type to one slot per day, scheduled
at the earliest available time.
</Text>
</View>
<Switch
value={props.onlyShowFirstAvailableSlot}
onValueChange={props.setOnlyShowFirstAvailableSlot}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Max Active Bookings Per Booker Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="mb-3 flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Limit number of upcoming bookings per booker
@@ -356,28 +351,46 @@ export function LimitsTab(props: LimitsTabProps) {
/>
</View>
{props.maxActiveBookingsPerBooker && (
<View className="mt-3">
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.maxActiveBookingsValue}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
const num = parseInt(numericValue) || 0;
if (num >= 0) {
props.setMaxActiveBookingsValue(numericValue || "1");
}
}}
placeholder="1"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<View className="mt-4 gap-3">
<View className="flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={props.maxActiveBookingsValue}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
const num = parseInt(numericValue) || 0;
if (num >= 0) {
props.setMaxActiveBookingsValue(numericValue || "1");
}
}}
placeholder="1"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<Text className="text-base text-[#666]">bookings</Text>
</View>
<TouchableOpacity
className="flex-row items-center"
onPress={() => props.setOfferReschedule(!props.offerReschedule)}
>
<View
className={`mr-3 h-5 w-5 items-center justify-center rounded border ${
props.offerReschedule ? "border-[#007AFF] bg-[#007AFF]" : "border-[#C7C7CC]"
}`}
>
{props.offerReschedule && <Ionicons name="checkmark" size={14} color="#FFFFFF" />}
</View>
<Text className="flex-1 text-sm text-[#333]">
Offer to reschedule the last booking to the new time slot
</Text>
</TouchableOpacity>
</View>
)}
</View>
{/* Limit Future Bookings Card */}
{/* 5. Limit Future Bookings Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="mb-3 flex-row items-start justify-between">
<View className="flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Limit future bookings</Text>
<Text className="text-sm leading-5 text-[#666]">
@@ -392,106 +405,87 @@ export function LimitsTab(props: LimitsTabProps) {
/>
</View>
{props.limitFutureBookings && (
<View className="mt-3 gap-3">
<View className="flex-row items-center gap-3">
<TouchableOpacity
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
props.futureBookingType === "rolling"
? "border-[#333] bg-[#F0F0F0]"
: "border-[#E5E5EA] bg-[#F8F9FA]"
<View className="mt-4 gap-3">
{/* Rolling option */}
<TouchableOpacity
className="flex-row items-start"
onPress={() => props.setFutureBookingType("rolling")}
>
<View
className={`mr-3 mt-0.5 h-5 w-5 items-center justify-center rounded-full border-2 ${
props.futureBookingType === "rolling" ? "border-[#007AFF]" : "border-[#C7C7CC]"
}`}
onPress={() => props.setFutureBookingType("rolling")}
>
<Text
className={`text-base ${
props.futureBookingType === "rolling"
? "font-semibold text-[#333]"
: "text-[#666]"
}`}
>
Rolling
</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
props.futureBookingType === "range"
? "border-[#333] bg-[#F0F0F0]"
: "border-[#E5E5EA] bg-[#F8F9FA]"
}`}
onPress={() => props.setFutureBookingType("range")}
>
<Text
className={`text-base ${
props.futureBookingType === "range"
? "font-semibold text-[#333]"
: "text-[#666]"
}`}
>
Date Range
</Text>
</TouchableOpacity>
</View>
{props.futureBookingType === "rolling" && (
<View className="gap-3">
<View className="flex-row items-center gap-3">
{props.futureBookingType === "rolling" && (
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
)}
</View>
<View className="flex-1">
<View className="flex-row flex-wrap items-center gap-2">
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
className="w-16 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-2 text-center text-base text-black"
value={props.rollingDays}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
const num = parseInt(numericValue) || 0;
if (num >= 0) {
props.setRollingDays(numericValue || "30");
}
props.setRollingDays(numericValue || "30");
props.setFutureBookingType("rolling");
}}
placeholder="30"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<TouchableOpacity
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
props.rollingCalendarDays
? "border-[#333] bg-[#F0F0F0]"
: "border-[#E5E5EA] bg-[#F8F9FA]"
}`}
onPress={() => props.setRollingCalendarDays(!props.rollingCalendarDays)}
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2"
onPress={() => {
props.setRollingCalendarDays(!props.rollingCalendarDays);
props.setFutureBookingType("rolling");
}}
>
<Text
className={`text-base ${
props.rollingCalendarDays ? "font-semibold text-[#333]" : "text-[#666]"
}`}
>
{props.rollingCalendarDays ? "Calendar days" : "Business days"}
<Text className="text-base text-black">
{props.rollingCalendarDays ? "calendar days" : "business days"}
</Text>
</TouchableOpacity>
</View>
<Text className="text-sm text-[#666]">days into the future</Text>
</View>
)}
{props.futureBookingType === "range" && (
<View className="gap-3">
<View>
<Text className="mb-1.5 text-sm text-[#666]">Start date</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.rangeStartDate}
onChangeText={props.setRangeStartDate}
placeholder="YYYY-MM-DD"
placeholderTextColor="#8E8E93"
/>
</View>
<View>
<Text className="mb-1.5 text-sm text-[#666]">End date</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.rangeEndDate}
onChangeText={props.setRangeEndDate}
placeholder="YYYY-MM-DD"
placeholderTextColor="#8E8E93"
/>
<Text className="text-base text-[#666]">into the future</Text>
</View>
</View>
)}
</TouchableOpacity>
{/* Date Range option */}
<TouchableOpacity
className="flex-row items-start"
onPress={() => props.setFutureBookingType("range")}
>
<View
className={`mr-3 mt-0.5 h-5 w-5 items-center justify-center rounded-full border-2 ${
props.futureBookingType === "range" ? "border-[#007AFF]" : "border-[#C7C7CC]"
}`}
>
{props.futureBookingType === "range" && (
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
)}
</View>
<View className="flex-1">
<Text className="mb-2 text-base text-[#333]">Within a date range</Text>
{props.futureBookingType === "range" && (
<View className="gap-2">
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2 text-base text-black"
value={props.rangeStartDate}
onChangeText={props.setRangeStartDate}
placeholder="Start date (YYYY-MM-DD)"
placeholderTextColor="#8E8E93"
/>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2 text-base text-black"
value={props.rangeEndDate}
onChangeText={props.setRangeEndDate}
placeholder="End date (YYYY-MM-DD)"
placeholderTextColor="#8E8E93"
/>
</View>
)}
</View>
</TouchableOpacity>
</View>
)}
</View>
@@ -0,0 +1,133 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { openInAppBrowser } from "../../../utils/browser";
interface RecurringTabProps {
recurringEnabled: boolean;
setRecurringEnabled: (value: boolean) => void;
recurringInterval: string;
setRecurringInterval: (value: string) => void;
recurringFrequency: "weekly" | "monthly" | "yearly";
setRecurringFrequency: (value: "weekly" | "monthly" | "yearly") => void;
recurringOccurrences: string;
setRecurringOccurrences: (value: string) => void;
setShowFrequencyDropdown: (show: boolean) => void;
}
// Map frequency to singular display text
const frequencyToLabel: Record<string, string> = {
weekly: "week",
monthly: "month",
yearly: "year",
};
export function RecurringTab({
recurringEnabled,
setRecurringEnabled,
recurringInterval,
setRecurringInterval,
recurringFrequency,
setRecurringFrequency,
recurringOccurrences,
setRecurringOccurrences,
setShowFrequencyDropdown,
}: RecurringTabProps) {
return (
<View className="gap-3">
{/* Recurring Event Toggle Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Recurring Event</Text>
<Text className="text-sm text-[#666]">
People can subscribe for recurring events.{" "}
<Text
className="text-[#007AFF]"
onPress={() =>
openInAppBrowser(
"https://cal.com/docs/core-features/event-types/recurring-events",
"Learn more about recurring events"
)
}
>
Learn more
</Text>
</Text>
</View>
<Switch
value={recurringEnabled}
onValueChange={setRecurringEnabled}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
{/* Recurring Configuration - shown when enabled */}
{recurringEnabled && (
<View className="mt-4 gap-4 border-t border-[#E5E5EA] pt-4">
{/* Repeats Every */}
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Repeats every</Text>
<View className="flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={recurringInterval}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
if (numericValue === "" || numericValue === "0") {
setRecurringInterval("1");
return;
}
const num = parseInt(numericValue);
if (num >= 1 && num <= 20) {
setRecurringInterval(numericValue);
}
}}
placeholder="1"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<TouchableOpacity
className="min-w-[100px] flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => setShowFrequencyDropdown(true)}
>
<Text className="text-base text-black">
{frequencyToLabel[recurringFrequency] || recurringFrequency}
</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
</View>
{/* For a maximum of */}
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">For a maximum of</Text>
<View className="flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={recurringOccurrences}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
if (numericValue === "" || numericValue === "0") {
setRecurringOccurrences("1");
return;
}
const num = parseInt(numericValue);
if (num >= 1) {
setRecurringOccurrences(numericValue);
}
}}
placeholder="12"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<Text className="text-base text-[#333]">Events</Text>
</View>
</View>
</View>
)}
</View>
</View>
);
}
@@ -0,0 +1,15 @@
/**
* Utility functions for Event Type Detail
*
* This file re-exports utilities from centralized locations for backward compatibility.
* New code should import directly from the source files.
*/
// Re-export partial update utilities
export { buildPartialUpdatePayload, hasChanges } from "./utils/buildPartialUpdatePayload";
// Re-export formatting utilities from centralized location
export { formatDuration, truncateTitle, formatAppIdToDisplayName } from "../../utils/formatters";
// Re-export location utilities from centralized location
export { displayNameToLocationValue } from "../../utils/locationHelpers";
@@ -0,0 +1,765 @@
import type { EventType } from "../../../services/calcom";
import type { LocationItem } from "../../../types/locations";
import { mapItemToApiLocation } from "../../../utils/locationHelpers";
import {
parseBufferTime,
parseMinimumNotice,
parseFrequencyUnit,
parseSlotInterval,
} from "../../../utils/eventTypeParsers";
interface FrequencyLimit {
id: number;
value: string;
unit: string;
}
interface EventTypeFormState {
eventTitle: string;
eventSlug: string;
eventDescription: string;
eventDuration: string;
isHidden: boolean;
locations: LocationItem[];
disableGuests: boolean;
allowMultipleDurations: boolean;
selectedDurations: string[];
defaultDuration: string;
selectedScheduleId?: number;
beforeEventBuffer: string;
afterEventBuffer: string;
minimumNoticeValue: string;
minimumNoticeUnit: string;
slotInterval: string;
limitBookingFrequency: boolean;
frequencyLimits: FrequencyLimit[];
limitTotalDuration: boolean;
durationLimits: FrequencyLimit[];
onlyShowFirstAvailableSlot: boolean;
maxActiveBookingsPerBooker: boolean;
maxActiveBookingsValue: string;
offerReschedule: boolean;
limitFutureBookings: boolean;
futureBookingType: "rolling" | "range";
rollingDays: string;
rollingCalendarDays: boolean;
rangeStartDate: string;
rangeEndDate: string;
// Advanced
requiresConfirmation: boolean;
requiresBookerEmailVerification: boolean;
hideCalendarNotes: boolean;
hideCalendarEventDetails: boolean;
hideOrganizerEmail: boolean;
lockTimezone: boolean;
allowReschedulingPastEvents: boolean;
allowBookingThroughRescheduleLink: boolean;
successRedirectUrl: string;
forwardParamsSuccessRedirect: boolean;
customReplyToEmail: string;
eventTypeColorLight: string;
eventTypeColorDark: string;
calendarEventName: string;
addToCalendarEmail: string;
selectedLayouts: string[];
defaultLayout: string;
disableCancelling: boolean;
disableRescheduling: boolean;
sendCalVideoTranscription: boolean;
autoTranslate: boolean;
// Seats
seatsEnabled: boolean;
seatsPerTimeSlot: string;
showAttendeeInfo: boolean;
showAvailabilityCount: boolean;
// Recurring
recurringEnabled: boolean;
recurringInterval: string;
recurringFrequency: "weekly" | "monthly" | "yearly";
recurringOccurrences: string;
}
function parseDurationString(duration: string): number {
const match = duration.match(/^(\d+)/);
return match ? parseInt(match[1], 10) : 0;
}
function hasMultipleDurationsChanged(
enabled: boolean,
selectedDurations: string[],
defaultDuration: string,
mainDuration: string,
original: any
): boolean {
const originalOptions = original?.lengthInMinutesOptions;
const originalHasMultiple =
originalOptions && Array.isArray(originalOptions) && originalOptions.length > 0;
if (!enabled && !originalHasMultiple) return false;
if (!enabled && originalHasMultiple) return true;
if (enabled && !originalHasMultiple) return true;
const currentDurations = selectedDurations.map(parseDurationString).sort((a, b) => a - b);
const originalDurations = [...originalOptions].sort((a: number, b: number) => a - b);
if (!deepEqual(currentDurations, originalDurations)) return true;
// Also check if the default (main) duration changed
const currentDefault = parseDurationString(defaultDuration || mainDuration);
const originalDefault = original?.lengthInMinutes;
return currentDefault !== originalDefault;
}
function deepEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a == null || b == null) return a == b;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, index) => deepEqual(item, b[index]));
}
if (typeof a === "object" && typeof b === "object") {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => deepEqual(a[key], b[key]));
}
return false;
}
function normalizeLocation(loc: any): any {
if (!loc) return null;
const normalized: any = { type: loc.type };
if (loc.type === "integration") {
normalized.integration = loc.integration;
} else if (loc.type === "address") {
normalized.address = loc.address || "";
if (loc.public !== undefined) normalized.public = loc.public;
} else if (loc.type === "link") {
normalized.link = loc.link || "";
if (loc.public !== undefined) normalized.public = loc.public;
} else if (loc.type === "phone") {
normalized.phone = loc.phone || "";
if (loc.public !== undefined) normalized.public = loc.public;
}
return normalized;
}
function haveLocationsChanged(
currentLocations: LocationItem[],
originalLocations: any[] | undefined
): boolean {
if ((!originalLocations || originalLocations.length === 0) && currentLocations.length === 0) {
return false;
}
if (!originalLocations && currentLocations.length > 0) return true;
if (originalLocations && currentLocations.length !== originalLocations.length) return true;
const currentMapped = currentLocations.map((loc) => normalizeLocation(mapItemToApiLocation(loc)));
const originalMapped = originalLocations!.map((loc) => normalizeLocation(loc));
const sortByType = (a: any, b: any) => (a?.type || "").localeCompare(b?.type || "");
currentMapped.sort(sortByType);
originalMapped.sort(sortByType);
return !deepEqual(currentMapped, originalMapped);
}
function hasBookingLimitsCountChanged(
enabled: boolean,
limits: FrequencyLimit[],
original: any
): boolean {
const originalIsDisabled =
!original ||
original.disabled === true ||
Object.keys(original).length === 0 ||
(Object.keys(original).length === 1 && original.disabled !== undefined);
if (!enabled && originalIsDisabled) return false;
if (!enabled && !originalIsDisabled) return true;
if (enabled && originalIsDisabled) return true;
const currentLimits: Record<string, number> = {};
limits.forEach((limit) => {
const unit = parseFrequencyUnit(limit.unit);
if (unit) {
currentLimits[unit] = parseInt(limit.value) || 1;
}
});
const originalLimits: Record<string, number> = {};
if (original) {
Object.keys(original).forEach((key) => {
if (key !== "disabled" && typeof original[key] === "number") {
originalLimits[key] = original[key];
}
});
}
return !deepEqual(currentLimits, originalLimits);
}
function hasBookingLimitsDurationChanged(
enabled: boolean,
limits: FrequencyLimit[],
original: any
): boolean {
const originalIsDisabled =
!original ||
original.disabled === true ||
Object.keys(original).length === 0 ||
(Object.keys(original).length === 1 && original.disabled !== undefined);
if (!enabled && originalIsDisabled) return false;
if (!enabled && !originalIsDisabled) return true;
if (enabled && originalIsDisabled) return true;
const currentLimits: Record<string, number> = {};
limits.forEach((limit) => {
const unit = parseFrequencyUnit(limit.unit);
if (unit) {
currentLimits[unit] = parseInt(limit.value) || 60;
}
});
const originalLimits: Record<string, number> = {};
if (original) {
Object.keys(original).forEach((key) => {
if (key !== "disabled" && typeof original[key] === "number") {
originalLimits[key] = original[key];
}
});
}
return !deepEqual(currentLimits, originalLimits);
}
function hasBookingWindowChanged(
enabled: boolean,
type: "rolling" | "range",
rollingDays: string,
calendarDays: boolean,
rangeStart: string,
rangeEnd: string,
original: any
): boolean {
const originalDisabled = !original || original.disabled;
if (!enabled && originalDisabled) return false;
if (!enabled && !originalDisabled) return true;
if (enabled && originalDisabled) return true;
if (type === "range") {
if (original.type !== "range") return true;
const originalValue = original.value;
if (!Array.isArray(originalValue)) return true;
return originalValue[0] !== rangeStart || originalValue[1] !== rangeEnd;
} else {
const expectedType = calendarDays ? "calendarDays" : "businessDays";
if (original.type !== expectedType) return true;
return original.value !== parseInt(rollingDays);
}
}
function hasBookerActiveBookingsLimitChanged(
enabled: boolean,
value: string,
offerReschedule: boolean,
original: any
): boolean {
const originalDisabled = !original || original.disabled;
if (!enabled && originalDisabled) return false;
if (!enabled && !originalDisabled) return true;
if (enabled && originalDisabled) return true;
const originalMax = original.maximumActiveBookings ?? original.count;
return originalMax !== parseInt(value) || original.offerReschedule !== offerReschedule;
}
function hasRecurrenceChanged(
enabled: boolean,
interval: string,
frequency: string,
occurrences: string,
original: any
): boolean {
const originalDisabled = !original || original.disabled === true;
if (!enabled && originalDisabled) return false;
if (!enabled && !originalDisabled) return true;
if (enabled && originalDisabled) return true;
return (
original.interval !== parseInt(interval) ||
original.frequency !== frequency ||
original.occurrences !== parseInt(occurrences)
);
}
function hasSeatsChanged(
enabled: boolean,
perTimeSlot: string,
showAttendee: boolean,
showAvailability: boolean,
original: any
): boolean {
const originalDisabled = !original || original.disabled === true;
const originalEnabled =
original &&
(original.disabled === false || (!("disabled" in original) && original.seatsPerTimeSlot));
if (!enabled && originalDisabled) return false;
if (!enabled && originalEnabled) return true;
if (enabled && originalDisabled) return true;
return (
original.seatsPerTimeSlot !== parseInt(perTimeSlot) ||
original.showAttendeeInfo !== showAttendee ||
original.showAvailabilityCount !== showAvailability
);
}
function mapLayoutToApi(layout: string): string {
const mapping: Record<string, string> = {
MONTH_VIEW: "month",
WEEK_VIEW: "week",
COLUMN_VIEW: "column",
month: "month",
week: "week",
column: "column",
};
return mapping[layout] || layout.toLowerCase().replace("_view", "");
}
function mapLayoutFromApi(layout: string): string {
const mapping: Record<string, string> = {
month: "MONTH_VIEW",
week: "WEEK_VIEW",
column: "COLUMN_VIEW",
MONTH_VIEW: "MONTH_VIEW",
WEEK_VIEW: "WEEK_VIEW",
COLUMN_VIEW: "COLUMN_VIEW",
};
return mapping[layout] || layout.toUpperCase() + "_VIEW";
}
function hasBookerLayoutsChanged(
selectedLayouts: string[],
defaultLayout: string,
original: any
): boolean {
if (!original) return selectedLayouts.length > 0;
const originalEnabled = original.enabledLayouts || [];
const originalDefault = original.defaultLayout;
const currentNormalized = selectedLayouts.map(mapLayoutToApi).sort();
const originalNormalized = originalEnabled.map((l: string) => mapLayoutToApi(l)).sort();
if (!deepEqual(currentNormalized, originalNormalized)) return true;
return mapLayoutToApi(defaultLayout) !== mapLayoutToApi(originalDefault || "");
}
function hasColorsChanged(lightColor: string, darkColor: string, original: any): boolean {
if (!original) return lightColor !== "#292929" || darkColor !== "#FAFAFA";
const originalLight = original.lightThemeHex || original.lightEventTypeColor;
const originalDark = original.darkThemeHex || original.darkEventTypeColor;
return lightColor !== originalLight || darkColor !== originalDark;
}
export function buildPartialUpdatePayload(
currentState: EventTypeFormState,
originalData: EventType | null
): Record<string, any> {
const payload: Record<string, any> = {};
if (!originalData) {
console.warn("buildPartialUpdatePayload called without original data");
return {};
}
const original = originalData as any;
if (currentState.eventTitle !== original.title) {
payload.title = currentState.eventTitle;
}
if (currentState.eventSlug !== original.slug) {
payload.slug = currentState.eventSlug;
}
if ((currentState.eventDescription || "") !== (original.description || "")) {
payload.description = currentState.eventDescription || "";
}
const currentDuration = parseInt(currentState.eventDuration);
if (
hasMultipleDurationsChanged(
currentState.allowMultipleDurations,
currentState.selectedDurations,
currentState.defaultDuration,
currentState.eventDuration,
original
)
) {
if (currentState.allowMultipleDurations && currentState.selectedDurations.length > 0) {
const durationOptions = currentState.selectedDurations
.map(parseDurationString)
.filter((d) => d > 0);
const defaultDurationValue = currentState.defaultDuration
? parseDurationString(currentState.defaultDuration)
: currentDuration;
payload.lengthInMinutes = defaultDurationValue;
payload.lengthInMinutesOptions = durationOptions;
} else {
payload.lengthInMinutes = currentDuration;
}
} else if (currentDuration !== original.lengthInMinutes && !currentState.allowMultipleDurations) {
payload.lengthInMinutes = currentDuration;
}
if (currentState.isHidden !== original.hidden) {
payload.hidden = currentState.isHidden;
}
if (currentState.disableGuests !== original.disableGuests) {
payload.disableGuests = currentState.disableGuests;
}
if (haveLocationsChanged(currentState.locations, original.locations)) {
if (currentState.locations.length > 0) {
payload.locations = currentState.locations.map((loc) => mapItemToApiLocation(loc));
} else {
payload.locations = [];
}
}
if (
currentState.selectedScheduleId !== undefined &&
currentState.selectedScheduleId !== original.scheduleId
) {
payload.scheduleId = currentState.selectedScheduleId;
}
const currentBeforeBuffer =
currentState.beforeEventBuffer === "No buffer time"
? 0
: parseBufferTime(currentState.beforeEventBuffer);
if (currentBeforeBuffer !== (original.beforeEventBuffer || 0)) {
payload.beforeEventBuffer = currentBeforeBuffer;
}
const currentAfterBuffer =
currentState.afterEventBuffer === "No buffer time"
? 0
: parseBufferTime(currentState.afterEventBuffer);
if (currentAfterBuffer !== (original.afterEventBuffer || 0)) {
payload.afterEventBuffer = currentAfterBuffer;
}
const currentMinimumNotice = parseMinimumNotice(
currentState.minimumNoticeValue,
currentState.minimumNoticeUnit
);
if (currentMinimumNotice !== (original.minimumBookingNotice || 0)) {
payload.minimumBookingNotice = currentMinimumNotice;
}
const currentSlotInterval =
currentState.slotInterval === "Default" ? null : parseSlotInterval(currentState.slotInterval);
if (currentSlotInterval !== (original.slotInterval || null)) {
payload.slotInterval = currentSlotInterval;
}
if (
hasBookingLimitsCountChanged(
currentState.limitBookingFrequency,
currentState.frequencyLimits,
original.bookingLimitsCount
)
) {
if (currentState.limitBookingFrequency && currentState.frequencyLimits.length > 0) {
const limitsCount: Record<string, number> = {};
currentState.frequencyLimits.forEach((limit) => {
const unit = parseFrequencyUnit(limit.unit);
if (unit) {
limitsCount[unit] = parseInt(limit.value) || 1;
}
});
payload.bookingLimitsCount = limitsCount;
} else {
payload.bookingLimitsCount = { disabled: true };
}
}
// === BOOKING LIMITS DURATION ===
if (
hasBookingLimitsDurationChanged(
currentState.limitTotalDuration,
currentState.durationLimits,
original.bookingLimitsDuration
)
) {
if (currentState.limitTotalDuration && currentState.durationLimits.length > 0) {
const limitsDuration: Record<string, number> = {};
currentState.durationLimits.forEach((limit) => {
const unit = parseFrequencyUnit(limit.unit);
if (unit) {
limitsDuration[unit] = parseInt(limit.value) || 60;
}
});
payload.bookingLimitsDuration = limitsDuration;
} else {
payload.bookingLimitsDuration = { disabled: true };
}
}
// === ONLY SHOW FIRST AVAILABLE SLOT ===
if (currentState.onlyShowFirstAvailableSlot !== (original.onlyShowFirstAvailableSlot || false)) {
payload.onlyShowFirstAvailableSlot = currentState.onlyShowFirstAvailableSlot;
}
// === BOOKER ACTIVE BOOKINGS LIMIT ===
if (
hasBookerActiveBookingsLimitChanged(
currentState.maxActiveBookingsPerBooker,
currentState.maxActiveBookingsValue,
currentState.offerReschedule,
original.bookerActiveBookingsLimit
)
) {
if (currentState.maxActiveBookingsPerBooker) {
payload.bookerActiveBookingsLimit = {
maximumActiveBookings: parseInt(currentState.maxActiveBookingsValue) || 1,
offerReschedule: currentState.offerReschedule,
};
} else {
payload.bookerActiveBookingsLimit = { disabled: true };
}
}
// === BOOKING WINDOW ===
if (
hasBookingWindowChanged(
currentState.limitFutureBookings,
currentState.futureBookingType,
currentState.rollingDays,
currentState.rollingCalendarDays,
currentState.rangeStartDate,
currentState.rangeEndDate,
original.bookingWindow
)
) {
if (currentState.limitFutureBookings) {
if (currentState.futureBookingType === "range") {
payload.bookingWindow = {
type: "range",
value: [currentState.rangeStartDate, currentState.rangeEndDate],
};
} else {
payload.bookingWindow = {
type: currentState.rollingCalendarDays ? "calendarDays" : "businessDays",
value: parseInt(currentState.rollingDays),
};
}
} else {
payload.bookingWindow = { disabled: true };
}
}
const originalRequiresConfirmation =
original.requiresConfirmation ||
(original.confirmationPolicy && !original.confirmationPolicy.disabled);
if (currentState.requiresConfirmation !== originalRequiresConfirmation) {
payload.requiresConfirmation = currentState.requiresConfirmation;
}
if (
currentState.requiresBookerEmailVerification !==
(original.requiresBookerEmailVerification || false)
) {
payload.requiresBookerEmailVerification = currentState.requiresBookerEmailVerification;
}
if (currentState.hideCalendarNotes !== (original.hideCalendarNotes || false)) {
payload.hideCalendarNotes = currentState.hideCalendarNotes;
}
if (currentState.hideCalendarEventDetails !== (original.hideCalendarEventDetails || false)) {
payload.hideCalendarEventDetails = currentState.hideCalendarEventDetails;
}
if (currentState.hideOrganizerEmail !== (original.hideOrganizerEmail || false)) {
payload.hideOrganizerEmail = currentState.hideOrganizerEmail;
}
if (currentState.lockTimezone !== (original.lockTimeZoneToggleOnBookingPage || false)) {
payload.lockTimeZoneToggleOnBookingPage = currentState.lockTimezone;
}
if (
currentState.allowReschedulingPastEvents !== (original.allowReschedulingPastBookings || false)
) {
payload.allowReschedulingPastBookings = currentState.allowReschedulingPastEvents;
}
if (
currentState.allowBookingThroughRescheduleLink !==
(original.allowReschedulingCancelledBookings || false)
) {
payload.allowReschedulingCancelledBookings = currentState.allowBookingThroughRescheduleLink;
}
if ((currentState.customReplyToEmail || "") !== (original.customReplyToEmail || "")) {
payload.customReplyToEmail = currentState.customReplyToEmail || null;
}
if ((currentState.successRedirectUrl || "") !== (original.successRedirectUrl || "")) {
payload.successRedirectUrl = currentState.successRedirectUrl || "";
}
if (
currentState.forwardParamsSuccessRedirect !== (original.forwardParamsSuccessRedirect || false)
) {
payload.forwardParamsSuccessRedirect = currentState.forwardParamsSuccessRedirect;
}
if (
hasBookerLayoutsChanged(
currentState.selectedLayouts,
currentState.defaultLayout,
original.bookerLayouts
)
) {
payload.bookerLayouts = {
enabledLayouts: currentState.selectedLayouts.map(mapLayoutToApi),
defaultLayout: mapLayoutToApi(currentState.defaultLayout),
};
}
const originalColor = original.color || original.eventTypeColor;
if (
hasColorsChanged(
currentState.eventTypeColorLight,
currentState.eventTypeColorDark,
originalColor
)
) {
payload.color = {
lightThemeHex: currentState.eventTypeColorLight,
darkThemeHex: currentState.eventTypeColorDark,
};
}
const metadataChanges: Record<string, any> = {};
const originalMetadata = original.metadata || {};
if (
currentState.disableCancelling !==
(originalMetadata.disableCancelling || original.disableCancelling || false)
) {
metadataChanges.disableCancelling = currentState.disableCancelling;
}
if (
currentState.disableRescheduling !==
(originalMetadata.disableRescheduling || original.disableRescheduling || false)
) {
metadataChanges.disableRescheduling = currentState.disableRescheduling;
}
if (
currentState.sendCalVideoTranscription !==
(originalMetadata.sendCalVideoTranscription || original.sendCalVideoTranscription || false)
) {
metadataChanges.sendCalVideoTranscription = currentState.sendCalVideoTranscription;
}
if (
currentState.autoTranslate !==
(originalMetadata.autoTranslate || original.autoTranslate || false)
) {
metadataChanges.autoTranslate = currentState.autoTranslate;
}
if ((currentState.calendarEventName || "") !== (originalMetadata.calendarEventName || "")) {
if (currentState.calendarEventName) {
metadataChanges.calendarEventName = currentState.calendarEventName;
}
}
if ((currentState.addToCalendarEmail || "") !== (originalMetadata.addToCalendarEmail || "")) {
if (currentState.addToCalendarEmail) {
metadataChanges.addToCalendarEmail = currentState.addToCalendarEmail;
}
}
if (Object.keys(metadataChanges).length > 0) {
payload.metadata = metadataChanges;
}
if (
hasRecurrenceChanged(
currentState.recurringEnabled,
currentState.recurringInterval,
currentState.recurringFrequency,
currentState.recurringOccurrences,
original.recurrence
)
) {
if (currentState.recurringEnabled) {
payload.recurrence = {
interval: parseInt(currentState.recurringInterval) || 1,
occurrences: parseInt(currentState.recurringOccurrences) || 12,
frequency: currentState.recurringFrequency,
};
} else {
payload.recurrence = { disabled: true };
}
}
if (
hasSeatsChanged(
currentState.seatsEnabled,
currentState.seatsPerTimeSlot,
currentState.showAttendeeInfo,
currentState.showAvailabilityCount,
original.seats
)
) {
if (currentState.seatsEnabled) {
payload.seats = {
seatsPerTimeSlot: parseInt(currentState.seatsPerTimeSlot) || 2,
showAttendeeInfo: currentState.showAttendeeInfo,
showAvailabilityCount: currentState.showAvailabilityCount,
};
} else {
payload.seats = { disabled: true };
}
}
return payload;
}
export function hasChanges(
currentState: EventTypeFormState,
originalData: EventType | null
): boolean {
const payload = buildPartialUpdatePayload(currentState, originalData);
return Object.keys(payload).length > 0;
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Cache Configuration for Cal.com Companion App
*
* This module provides centralized cache configuration with environment variable support.
* All cache durations are configurable via EXPO_PUBLIC_ prefixed environment variables.
*/
// Helper to parse environment variable to number with fallback
const getEnvNumber = (key: string, fallback: number): number => {
const value = process.env[key];
if (value === undefined || value === "") {
return fallback;
}
const parsed = parseInt(value, 10);
return isNaN(parsed) ? fallback : parsed;
};
// Convert minutes to milliseconds
// -1 means "never stale" (Infinity)
const minutesToMs = (minutes: number): number => {
if (minutes < 0) return Infinity;
return minutes * 60 * 1000;
};
/**
* Default cache durations in minutes
* Use -1 to indicate "never stale" (Infinity) - data only refreshes on manual reload or mutations
*/
const DEFAULT_STALE_TIME_MINUTES = 5;
const DEFAULT_BOOKINGS_STALE_TIME_MINUTES = 5;
const DEFAULT_EVENT_TYPES_STALE_TIME_MINUTES = -1; // Never stale - only refresh on mutations
const DEFAULT_SCHEDULES_STALE_TIME_MINUTES = -1; // Never stale - only refresh on mutations
const DEFAULT_USER_PROFILE_STALE_TIME_MINUTES = -1; // Never stale - only refresh on manual reload
const DEFAULT_GC_TIME_MINUTES = 1440; // Keep cached data for 24 hours (full day offline support)
/**
* Cache configuration object with all settings
*/
export const CACHE_CONFIG = {
/**
* Default stale time for all queries (in milliseconds)
* Data older than this is considered stale and will be refetched in background
*/
defaultStaleTime: minutesToMs(
getEnvNumber("EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES", DEFAULT_STALE_TIME_MINUTES)
),
/**
* Garbage collection time (in milliseconds)
* Unused cache entries are removed after this duration
*/
gcTime: minutesToMs(getEnvNumber("EXPO_PUBLIC_CACHE_GC_TIME_MINUTES", DEFAULT_GC_TIME_MINUTES)),
/**
* Resource-specific cache configurations
*
* Stale time determines when data is considered "stale" and should be refetched:
* - Bookings: 5 min - moderate refresh rate since bookings can change externally
* - Event Types: Infinity - only refresh on mutations (create/update/delete) or manual pull-to-refresh
* - Schedules: Infinity - only refresh on mutations (create/update/delete) or manual pull-to-refresh
* - User Profile: Infinity - only refresh on manual pull-to-refresh (rarely changes)
*/
bookings: {
staleTime: minutesToMs(
getEnvNumber(
"EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES",
DEFAULT_BOOKINGS_STALE_TIME_MINUTES
)
),
},
eventTypes: {
/** Infinity = never stale, only refreshes on mutations or manual reload */
staleTime: minutesToMs(
getEnvNumber(
"EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES",
DEFAULT_EVENT_TYPES_STALE_TIME_MINUTES
)
),
},
schedules: {
/** Infinity = never stale, only refreshes on mutations or manual reload */
staleTime: minutesToMs(
getEnvNumber(
"EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES",
DEFAULT_SCHEDULES_STALE_TIME_MINUTES
)
),
},
userProfile: {
/** Infinity = never stale, only refreshes on manual reload */
staleTime: minutesToMs(
getEnvNumber(
"EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES",
DEFAULT_USER_PROFILE_STALE_TIME_MINUTES
)
),
},
/**
* Refetch behavior configuration
*/
refetch: {
onWindowFocus: true,
onReconnect: true,
onMount: false,
},
/**
* Retry configuration for failed queries
*/
retry: {
count: 3,
delay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
/**
* Persistence configuration
*/
persistence: {
/** Key prefix for persisted cache in storage */
storageKey: "cal-companion-query-cache",
/** Maximum age of persisted cache before it's discarded (24 hours) */
maxAge: 24 * 60 * 60 * 1000,
/** Throttle time for persisting cache to storage (1 second) */
throttleTime: 1000,
},
} as const;
/**
* Query key factory for consistent cache key generation
* Using array-based keys enables granular cache invalidation
*/
export const queryKeys = {
// Bookings
bookings: {
all: ["bookings"] as const,
lists: () => [...queryKeys.bookings.all, "list"] as const,
list: (filters: Record<string, unknown>) => [...queryKeys.bookings.lists(), filters] as const,
details: () => [...queryKeys.bookings.all, "detail"] as const,
detail: (uid: string) => [...queryKeys.bookings.details(), uid] as const,
},
// Event Types
eventTypes: {
all: ["eventTypes"] as const,
lists: () => [...queryKeys.eventTypes.all, "list"] as const,
list: (filters?: Record<string, unknown>) =>
filters
? ([...queryKeys.eventTypes.lists(), filters] as const)
: queryKeys.eventTypes.lists(),
details: () => [...queryKeys.eventTypes.all, "detail"] as const,
detail: (id: number) => [...queryKeys.eventTypes.details(), id] as const,
},
// Schedules (Availability)
schedules: {
all: ["schedules"] as const,
lists: () => [...queryKeys.schedules.all, "list"] as const,
list: (filters?: Record<string, unknown>) =>
filters ? ([...queryKeys.schedules.lists(), filters] as const) : queryKeys.schedules.lists(),
details: () => [...queryKeys.schedules.all, "detail"] as const,
detail: (id: number) => [...queryKeys.schedules.details(), id] as const,
},
// User Profile
userProfile: {
all: ["userProfile"] as const,
current: () => [...queryKeys.userProfile.all, "current"] as const,
},
// Conferencing
conferencing: {
all: ["conferencing"] as const,
options: () => [...queryKeys.conferencing.all, "options"] as const,
},
// Webhooks
webhooks: {
all: ["webhooks"] as const,
global: () => [...queryKeys.webhooks.all, "global"] as const,
eventType: (eventTypeId: number) =>
[...queryKeys.webhooks.all, "eventType", eventTypeId] as const,
},
// Private Links
privateLinks: {
all: ["privateLinks"] as const,
eventType: (eventTypeId: number) => [...queryKeys.privateLinks.all, eventTypeId] as const,
},
} as const;
/**
* Type exports for query keys
*/
export type QueryKeys = typeof queryKeys;
export type BookingQueryKeys = typeof queryKeys.bookings;
export type EventTypeQueryKeys = typeof queryKeys.eventTypes;
export type ScheduleQueryKeys = typeof queryKeys.schedules;
export type UserProfileQueryKeys = typeof queryKeys.userProfile;
+12
View File
@@ -0,0 +1,12 @@
/**
* Config Index
*
* Central export point for all configuration.
*
* @example
* ```tsx
* import { CACHE_CONFIG, queryKeys } from '../config';
* ```
*/
export { CACHE_CONFIG, queryKeys } from "./cache.config";
+13 -97
View File
@@ -1,8 +1,4 @@
/// <reference types="chrome" />
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import { WebAuthService } from "../services/webAuth";
import { CalComAPIService } from "../services/calcom";
import {
@@ -10,6 +6,7 @@ import {
OAuthTokens,
CalComOAuthService,
} from "../services/oauthService";
import { secureStorage } from "../utils/storage";
interface AuthContextType {
isAuthenticated: boolean;
@@ -36,98 +33,8 @@ interface AuthProviderProps {
children: ReactNode;
}
// Check if chrome.storage is available (browser extension context)
const isChromeStorageAvailable = (): boolean => {
return (
Platform.OS === "web" &&
typeof chrome !== "undefined" &&
chrome.storage !== undefined &&
chrome.storage.local !== undefined
);
};
// Unified storage helper to abstract web/mobile/extension differences
const storage = {
get: async (key: string): Promise<string | null> => {
// Use chrome.storage in browser extension context (most secure)
if (isChromeStorageAvailable()) {
return new Promise((resolve) => {
chrome.storage.local.get([key], (result) => {
resolve(result[key] || null);
});
});
}
// Fall back to localStorage for regular web apps
if (Platform.OS === "web") {
return localStorage.getItem(key);
}
// Use SecureStore for mobile
return await SecureStore.getItemAsync(key);
},
set: async (key: string, value: string): Promise<void> => {
// Use chrome.storage in browser extension context (most secure)
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
// Fall back to localStorage for regular web apps
if (Platform.OS === "web") {
localStorage.setItem(key, value);
return;
}
// Use SecureStore for mobile
await SecureStore.setItemAsync(key, value);
},
remove: async (key: string): Promise<void> => {
// Use chrome.storage in browser extension context (most secure)
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.remove(key, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
// Fall back to localStorage for regular web apps
if (Platform.OS === "web") {
localStorage.removeItem(key);
return;
}
// Use SecureStore for mobile
await SecureStore.deleteItemAsync(key);
},
removeAll: async (keys: string[]): Promise<void> => {
// Use chrome.storage in browser extension context (most secure)
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.remove(keys, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
// Fall back to localStorage for regular web apps
if (Platform.OS === "web") {
keys.forEach((key) => localStorage.removeItem(key));
return;
}
// Use SecureStore for mobile
await Promise.all(keys.map((key) => SecureStore.deleteItemAsync(key)));
},
};
// Use the shared secure storage adapter
const storage = secureStorage;
export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -161,7 +68,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
CalComAPIService.setAccessToken(token, refreshToken);
try {
await CalComAPIService.getUserProfile();
const profile = await CalComAPIService.getUserProfile();
// Store user info for use in the app (e.g., to display "You" in bookings)
if (profile) {
setUserInfo({
email: profile.email,
name: profile.name,
id: profile.id,
username: profile.username,
});
}
} catch (profileError) {
console.error("Failed to fetch user profile:", profileError);
// Don't fail login if profile fetch fails
+200
View File
@@ -0,0 +1,200 @@
/**
* React Query Context Provider
*
* This module sets up React Query with:
* - Optimized default configurations
* - Offline persistence support
* - Environment-based cache duration settings
* - Cross-platform compatibility (mobile + extension)
*/
import React, { ReactNode, useState, useEffect, useCallback, useMemo } from "react";
import { Platform } from "react-native";
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { CACHE_CONFIG } from "../config/cache.config";
import { createQueryPersister, clearQueryCache } from "../utils/queryPersister";
/**
* Create and configure the QueryClient instance
*/
const createQueryClient = (): QueryClient => {
return new QueryClient({
defaultOptions: {
queries: {
// How long data is considered fresh
staleTime: CACHE_CONFIG.defaultStaleTime,
// How long to keep unused data in cache
gcTime: CACHE_CONFIG.gcTime,
// Refetch behavior
refetchOnWindowFocus: CACHE_CONFIG.refetch.onWindowFocus,
refetchOnReconnect: CACHE_CONFIG.refetch.onReconnect,
refetchOnMount: CACHE_CONFIG.refetch.onMount,
// Retry configuration
retry: CACHE_CONFIG.retry.count,
retryDelay: CACHE_CONFIG.retry.delay,
// Network mode - always try to fetch, use cache as fallback
networkMode: "offlineFirst",
},
mutations: {
// Retry failed mutations
retry: 1,
retryDelay: 1000,
// Network mode for mutations
networkMode: "offlineFirst",
},
},
});
};
/**
* Props for the QueryProvider component
*/
interface QueryProviderProps {
children: ReactNode;
}
/**
* Context for exposing query utilities
*/
interface QueryContextValue {
/** Invalidate all queries and refetch */
invalidateAllQueries: () => Promise<void>;
/** Clear the persisted cache */
clearCache: () => Promise<void>;
/** Check if the app is online */
isOnline: boolean;
}
const QueryContext = React.createContext<QueryContextValue | undefined>(undefined);
/**
* QueryProvider component that wraps the app with React Query functionality
*
* Features:
* - Automatic cache persistence to device storage
* - Online/offline detection
* - Configurable cache durations via environment variables
*/
export function QueryProvider({ children }: QueryProviderProps) {
// Create QueryClient instance (stable reference)
const [queryClient] = useState(() => createQueryClient());
// Create persister instance (stable reference)
const [persister] = useState(() => createQueryPersister());
// Track online status
const [isOnline, setIsOnline] = useState(true);
// Setup online/offline detection
useEffect(() => {
// For web/extension
if (Platform.OS === "web") {
const handleOnline = () => {
setIsOnline(true);
onlineManager.setOnline(true);
};
const handleOffline = () => {
setIsOnline(false);
onlineManager.setOnline(false);
};
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Set initial state
setIsOnline(navigator.onLine);
onlineManager.setOnline(navigator.onLine);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}
// For React Native, we could use NetInfo but it requires additional setup
// For now, assume online on mobile (React Query handles network errors gracefully)
return undefined;
}, []);
// Listen for reload messages from extension
useEffect(() => {
if (Platform.OS === "web") {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "cal-companion-reload-cache") {
queryClient.invalidateQueries();
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}
return undefined;
}, [queryClient]);
/**
* Invalidate all queries and trigger refetch
*/
const invalidateAllQueries = useCallback(async () => {
await queryClient.invalidateQueries();
}, [queryClient]);
/**
* Clear the persisted cache
*/
const clearCache = useCallback(async () => {
queryClient.clear();
await clearQueryCache();
}, [queryClient]);
const contextValue: QueryContextValue = useMemo(
() => ({
invalidateAllQueries,
clearCache,
isOnline,
}),
[invalidateAllQueries, clearCache, isOnline]
);
return (
<QueryContext.Provider value={contextValue}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: CACHE_CONFIG.persistence.maxAge,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
return query.state.status === "success";
},
},
}}
>
{children}
</PersistQueryClientProvider>
</QueryContext.Provider>
);
}
/**
* Hook to access query utilities
*/
export function useQueryContext(): QueryContextValue {
const context = React.useContext(QueryContext);
if (context === undefined) {
throw new Error("useQueryContext must be used within a QueryProvider");
}
return context;
}
/**
* Export for direct QueryClient access when needed
* (e.g., for prefetching outside of components)
*/
export { QueryClient };
+16
View File
@@ -0,0 +1,16 @@
/**
* Contexts Index
*
* Central export point for all React contexts.
*
* @example
* ```tsx
* import { AuthProvider, useAuth, QueryProvider } from '../contexts';
* ```
*/
// Auth context
export { AuthProvider, useAuth } from "./AuthContext";
// Query context
export { QueryProvider, useQueryContext } from "./QueryContext";
@@ -1,5 +1,18 @@
/// <reference types="chrome" />
// ============================================
// DEV ONLY: API Key for localhost testing
// TODO: REMOVE THIS ENTIRE SECTION BEFORE PRODUCTION
// ============================================
const DEV_API_KEY = import.meta.env.EXPO_PUBLIC_CAL_API_KEY as string | undefined;
const IS_DEV_MODE = DEV_API_KEY && DEV_API_KEY.length > 0;
if (IS_DEV_MODE) {
console.log("Cal.com Extension: DEV MODE - API Key authentication enabled for testing");
}
// ============================================
// END DEV ONLY SECTION
// ============================================
// @ts-ignore - WXT provides this globally
export default defineBackground(() => {
chrome.action.onClicked.addListener((tab) => {
@@ -163,19 +176,38 @@ async function handleTokenExchange(
async function fetchEventTypes() {
const API_BASE_URL = "https://api.cal.com/v2";
// Determine authentication method
let authHeader: string;
let authMethod: "oauth" | "apikey";
const result = await chrome.storage.local.get(["cal_oauth_tokens"]);
const oauthTokens = result.cal_oauth_tokens
? JSON.parse(result.cal_oauth_tokens as string)
: null;
if (!oauthTokens?.accessToken) {
if (oauthTokens?.accessToken) {
// Use OAuth token if available
authHeader = `Bearer ${oauthTokens.accessToken}`;
authMethod = "oauth";
} else if (IS_DEV_MODE && DEV_API_KEY) {
// ============================================
// DEV ONLY: Fallback to API key for localhost testing
// TODO: REMOVE THIS BLOCK BEFORE PRODUCTION
// ============================================
console.log("Cal.com Extension: Using API Key for authentication (DEV MODE)");
authHeader = `Bearer ${DEV_API_KEY}`;
authMethod = "apikey";
// ============================================
// END DEV ONLY BLOCK
// ============================================
} else {
throw new Error("No OAuth access token found. Please sign in with OAuth.");
}
// Get current user to retrieve username
const userResponse = await fetch(`${API_BASE_URL}/me`, {
headers: {
Authorization: `Bearer ${oauthTokens.accessToken}`,
Authorization: authHeader,
"Content-Type": "application/json",
"cal-api-version": "2024-06-11",
},
@@ -198,7 +230,7 @@ async function fetchEventTypes() {
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${oauthTokens.accessToken}`,
Authorization: authHeader,
"Content-Type": "application/json",
"cal-api-version": "2024-06-14",
},
+85 -109
View File
@@ -230,6 +230,29 @@ export default defineContentScript({
toggleButton.style.justifyContent = "center";
toggleButton.title = "Toggle sidebar";
// Create reload button
const reloadButton = document.createElement("button");
reloadButton.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4V10H7" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23 20V14H17" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10M23 14L18.36 18.36A9 9 0 0 1 3.51 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
reloadButton.style.width = "40px";
reloadButton.style.height = "40px";
reloadButton.style.borderRadius = "50%";
reloadButton.style.border = "1px solid rgba(255, 255, 255, 0.5)";
reloadButton.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
reloadButton.style.backdropFilter = "blur(10px)";
reloadButton.style.color = "white";
reloadButton.style.cursor = "pointer";
reloadButton.style.fontSize = "16px";
reloadButton.style.boxShadow = "0 2px 8px rgba(0,0,0,0.2)";
reloadButton.style.transition = "all 0.2s ease";
reloadButton.style.display = "flex";
reloadButton.style.alignItems = "center";
reloadButton.style.justifyContent = "center";
reloadButton.title = "Reload data";
// Create close button
const closeButton = document.createElement("button");
closeButton.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -268,6 +291,37 @@ export default defineContentScript({
closeButton.style.transform = "scale(1)";
});
reloadButton.addEventListener("mouseenter", () => {
reloadButton.style.transform = "scale(1.1)";
});
reloadButton.addEventListener("mouseleave", () => {
reloadButton.style.transform = "scale(1)";
});
// Reload functionality - sends message to iframe to invalidate cache
reloadButton.addEventListener("click", () => {
// Add spinning animation
reloadButton.style.animation = "spin 0.5s ease-in-out";
setTimeout(() => {
reloadButton.style.animation = "";
}, 500);
// Send message to iframe to reload cache
if (iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: "cal-companion-reload-cache" }, "*");
}
});
// Add spin animation style
const styleSheet = document.createElement("style");
styleSheet.textContent = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(styleSheet);
// Toggle functionality
toggleButton.addEventListener("click", () => {
if (isClosed) return;
@@ -300,6 +354,7 @@ export default defineContentScript({
// Add buttons to container
buttonsContainer.appendChild(toggleButton);
buttonsContainer.appendChild(reloadButton);
buttonsContainer.appendChild(closeButton);
// Add everything to DOM
@@ -1508,108 +1563,6 @@ export default defineContentScript({
}
}
/**
* Auto-remove all Cal.com action bars before sending email
*/
function setupAutoRemoveOnSend() {
try {
// Helper function to remove all action bars and marked Google chips
const removeAllActionBars = () => {
// Remove action bars
const allActionBars = document.querySelectorAll(".cal-companion-action-bar");
if (allActionBars.length > 0) {
console.log(`Cal.com: Removing ${allActionBars.length} action bar(s) before send`);
allActionBars.forEach((bar) => {
try {
// Call cleanup function to remove event listeners before removing DOM node
if ((bar as any).__cleanup) {
(bar as any).__cleanup();
}
bar.remove();
} catch (error) {
console.warn("Cal.com: Failed to remove action bar:", error);
}
});
}
// Remove Google chips that were marked for removal (user used Cal.com)
const markedChips = document.querySelectorAll(
'.gmail_chip[data-calcom-remove-on-send="true"]'
);
if (markedChips.length > 0) {
console.log(
`Cal.com: Removing ${markedChips.length} Google chip(s) before send (user used Cal.com)`
);
markedChips.forEach((chip) => {
try {
chip.remove();
} catch (error) {
console.warn("Cal.com: Failed to remove Google chip:", error);
}
});
}
};
// Method 1: Watch for clicks on Send button
document.addEventListener(
"click",
(e) => {
const target = e.target as HTMLElement;
// Check if the clicked element is a Send button
const isSendButton =
target.getAttribute("data-tooltip")?.includes("Send") ||
target.getAttribute("aria-label")?.includes("Send") ||
target.textContent?.trim() === "Send" ||
target.closest('[data-tooltip*="Send"]') ||
target.closest('[aria-label*="Send"]') ||
target
.closest('[role="button"][data-tooltip]')
?.getAttribute("data-tooltip")
?.includes("Send");
if (isSendButton) {
console.log("Cal.com: Send button clicked");
removeAllActionBars();
}
},
true
); // Use capture phase
// Method 2: Watch for keyboard shortcuts (Ctrl+Enter / Cmd+Enter)
document.addEventListener(
"keydown",
(e) => {
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
const isEnter = e.key === "Enter";
if (isCtrlOrCmd && isEnter) {
// Check if we're in a compose window
const activeElement = document.activeElement;
const isInCompose =
activeElement?.getAttribute("role") === "textbox" ||
activeElement?.getAttribute("contenteditable") === "true" ||
activeElement?.closest('[role="textbox"]');
if (isInCompose) {
console.log("Cal.com: Send keyboard shortcut detected (Ctrl/Cmd+Enter)");
removeAllActionBars();
}
}
},
true
);
// Note: Action bars are now overlays (like Grammarly), so they won't be included in emails.
// We keep the click and keyboard listeners for clean UI (removing overlays when sending).
// Removed the MutationObserver as it was too aggressive and removing action bars prematurely.
console.log("Cal.com: Auto-remove on send listeners added (click, keyboard)");
} catch (error) {
console.warn("Cal.com: Failed to setup auto-remove on send:", error);
}
}
/**
* Watch for Google Calendar scheduling chips and add Cal.com suggestion button
*/
@@ -1973,8 +1926,16 @@ export default defineContentScript({
showGmailNotification("Cal.com embed inserted!", "success");
console.log("Cal.com: ✅ Email embed inserted successfully");
// Mark chip for removal on send (don't remove yet - keep it visible for user reference)
chipElement.setAttribute("data-calcom-remove-on-send", "true");
// Immediately remove the Google chip and action bar
try {
chipElement.remove();
if ((actionBar as any).__cleanup) {
(actionBar as any).__cleanup();
}
actionBar.remove();
} catch (removeError) {
console.warn("Cal.com: Failed to remove chip/action bar:", removeError);
}
} else {
showGmailNotification("Failed to insert embed", "error");
}
@@ -2660,8 +2621,26 @@ export default defineContentScript({
showGmailNotification("Cal.com link inserted!", "success");
backdrop.remove();
// Mark chip for removal on send (don't remove yet - keep it visible for user reference)
chipElement.setAttribute("data-calcom-remove-on-send", "true");
// Immediately remove the Google chip and its action bar
try {
const scheduleId = chipElement.getAttribute("data-ad-hoc-schedule-id");
const actionBar = scheduleId
? document.querySelector(
`.cal-companion-action-bar[data-schedule-id="${scheduleId}"]`
)
: chipElement.parentElement?.querySelector(".cal-companion-action-bar");
chipElement.remove();
if (actionBar) {
if ((actionBar as any).__cleanup) {
(actionBar as any).__cleanup();
}
actionBar.remove();
}
} catch (removeError) {
console.warn("Cal.com: Failed to remove chip/action bar:", removeError);
}
} else {
showGmailNotification("Failed to insert link", "error");
}
@@ -2911,9 +2890,6 @@ export default defineContentScript({
// Start watching for Google Calendar chips
watchForGoogleChips();
// Setup auto-remove action bars before sending email
setupAutoRemoveOnSend();
}
},
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Query Hooks Index
*
* Central export point for all React Query hooks.
* Import hooks from this file for clean imports:
*
* @example
* ```tsx
* import { useBookings, useEventTypes, useSchedules } from '../hooks';
* ```
*/
// Bookings hooks
export {
useBookings,
useBookingByUid,
useCancelBooking,
useRescheduleBooking,
useConfirmBooking,
useDeclineBooking,
usePrefetchBookings,
useInvalidateBookings,
type BookingFilters,
type Booking,
} from "./useBookings";
// Event Types hooks
export {
useEventTypes,
useEventTypeById,
useCreateEventType,
useUpdateEventType,
useDeleteEventType,
useDuplicateEventType,
usePrefetchEventTypes,
useInvalidateEventTypes,
type EventType,
type CreateEventTypeInput,
} from "./useEventTypes";
// Schedules (Availability) hooks
export {
useSchedules,
useScheduleById,
useCreateSchedule,
useUpdateSchedule,
useSetScheduleAsDefault,
useDeleteSchedule,
useDuplicateSchedule,
usePrefetchSchedules,
useInvalidateSchedules,
type Schedule,
type CreateScheduleInput,
type UpdateScheduleInput,
} from "./useSchedules";
// User Profile hooks
export {
useUserProfile,
useUsername,
useUpdateUserProfile,
usePrefetchUserProfile,
useInvalidateUserProfile,
type UserProfile,
type UpdateUserProfileInput,
} from "./useUserProfile";
// Re-export query keys for advanced use cases
export { queryKeys } from "../config/cache.config";
// Re-export query context utilities
export { useQueryContext } from "../contexts/QueryContext";
+267
View File
@@ -0,0 +1,267 @@
/**
* Bookings Query Hooks
*
* This module provides React Query hooks for fetching and mutating bookings data.
* It integrates with the existing CalComAPIService and provides:
* - Automatic caching with configurable stale times
* - Pull-to-refresh support via refetch
* - Optimistic updates for mutations
* - Cache invalidation on mutations
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CalComAPIService, Booking } from "../services/calcom";
import { CACHE_CONFIG, queryKeys } from "../config/cache.config";
/**
* Filter options for fetching bookings
*/
export interface BookingFilters {
status?: string[];
fromDate?: string;
toDate?: string;
eventTypeId?: number;
limit?: number;
offset?: number;
[key: string]: unknown; // Index signature for Record<string, unknown> compatibility
}
/**
* Hook to fetch bookings with optional filters
*
* @param filters - Optional filters for the bookings query
* @returns Query result with bookings data, loading state, error, and refetch function
*
* @example
* ```tsx
* const { data: bookings, isLoading, refetch } = useBookings({ status: ['upcoming'] });
*
* // Pull-to-refresh
* <RefreshControl refreshing={isRefetching} onRefresh={refetch} />
* ```
*/
export function useBookings(filters?: BookingFilters) {
return useQuery({
queryKey: queryKeys.bookings.list(filters || {}),
queryFn: () => CalComAPIService.getBookings(filters),
staleTime: CACHE_CONFIG.bookings.staleTime,
// Keep previous data while fetching new data (smoother UX)
placeholderData: (previousData) => previousData,
// Don't retry on network errors (keeps cache intact)
retry: (failureCount, error) => {
// Don't retry network errors - keeps cached data visible
if (error?.message?.includes("Network") || error?.message?.includes("fetch")) {
return false;
}
return failureCount < 2;
},
// Keep showing cached data even if refetch fails
refetchOnReconnect: true,
});
}
/**
* Hook to fetch a single booking by UID
*
* @param uid - The unique identifier of the booking
* @returns Query result with booking data
*
* @example
* ```tsx
* const { data: booking, isLoading } = useBookingByUid('abc-123');
* ```
*/
export function useBookingByUid(uid: string | undefined) {
return useQuery({
queryKey: queryKeys.bookings.detail(uid || ""),
queryFn: () => CalComAPIService.getBookingByUid(uid!),
enabled: !!uid, // Only fetch when uid is provided
staleTime: CACHE_CONFIG.bookings.staleTime,
});
}
/**
* Hook to cancel a booking
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: cancelBooking, isPending } = useCancelBooking();
*
* cancelBooking({ uid: 'abc-123', reason: 'No longer needed' });
* ```
*/
export function useCancelBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ uid, reason }: { uid: string; reason?: string }) =>
CalComAPIService.cancelBooking(uid, reason),
onSuccess: (_, variables) => {
// Invalidate all booking queries to refetch fresh data
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
// Also invalidate the specific booking detail
queryClient.invalidateQueries({
queryKey: queryKeys.bookings.detail(variables.uid),
});
},
onError: (error) => {
console.error("Failed to cancel booking:", error);
},
});
}
/**
* Hook to reschedule a booking
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: rescheduleBooking, isPending } = useRescheduleBooking();
*
* rescheduleBooking({
* uid: 'abc-123',
* start: '2024-01-15T10:00:00Z',
* reschedulingReason: 'Conflict with another meeting'
* });
* ```
*/
export function useRescheduleBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
uid,
start,
reschedulingReason,
}: {
uid: string;
start: string;
reschedulingReason?: string;
}) => CalComAPIService.rescheduleBooking(uid, { start, reschedulingReason }),
onSuccess: (_, variables) => {
// Invalidate all booking queries
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
// Also invalidate the specific booking detail
queryClient.invalidateQueries({
queryKey: queryKeys.bookings.detail(variables.uid),
});
},
onError: (error) => {
console.error("Failed to reschedule booking:", error);
},
});
}
/**
* Hook to confirm a pending booking
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: confirmBooking, isPending } = useConfirmBooking();
*
* confirmBooking({ uid: 'abc-123' });
* ```
*/
export function useConfirmBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ uid }: { uid: string }) => CalComAPIService.confirmBooking(uid),
onSuccess: (_, variables) => {
// Invalidate all booking queries to refetch fresh data
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
// Also invalidate the specific booking detail
queryClient.invalidateQueries({
queryKey: queryKeys.bookings.detail(variables.uid),
});
},
onError: (error) => {
console.error("Failed to confirm booking:", error);
},
});
}
/**
* Hook to decline a pending booking
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: declineBooking, isPending } = useDeclineBooking();
*
* declineBooking({ uid: 'abc-123', reason: 'Schedule conflict' });
* ```
*/
export function useDeclineBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ uid, reason }: { uid: string; reason?: string }) =>
CalComAPIService.declineBooking(uid, reason),
onSuccess: (_, variables) => {
// Invalidate all booking queries to refetch fresh data
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
// Also invalidate the specific booking detail
queryClient.invalidateQueries({
queryKey: queryKeys.bookings.detail(variables.uid),
});
},
onError: (error) => {
console.error("Failed to decline booking:", error);
},
});
}
/**
* Hook to prefetch bookings (useful for navigation)
*
* @returns Function to prefetch bookings
*
* @example
* ```tsx
* const prefetchBookings = usePrefetchBookings();
*
* // Prefetch when user hovers over bookings tab
* onHover={() => prefetchBookings({ status: ['upcoming'] })}
* ```
*/
export function usePrefetchBookings() {
const queryClient = useQueryClient();
return (filters?: BookingFilters) => {
queryClient.prefetchQuery({
queryKey: queryKeys.bookings.list(filters || {}),
queryFn: () => CalComAPIService.getBookings(filters),
staleTime: CACHE_CONFIG.bookings.staleTime,
});
};
}
/**
* Hook to invalidate all bookings cache
* Useful when you know data has changed externally
*
* @returns Function to invalidate bookings cache
*/
export function useInvalidateBookings() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all });
};
}
/**
* Type exports for consumers
*/
export type { Booking };
+274
View File
@@ -0,0 +1,274 @@
/**
* Event Types Query Hooks
*
* This module provides React Query hooks for fetching and mutating event types.
* It integrates with the existing CalComAPIService and provides:
* - Automatic caching with configurable stale times
* - Pull-to-refresh support via refetch
* - Optimistic updates for mutations
* - Cache invalidation on create/update/delete
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CalComAPIService, EventType, CreateEventTypeInput } from "../services/calcom";
import { CACHE_CONFIG, queryKeys } from "../config/cache.config";
/**
* Hook to fetch all event types
*
* @returns Query result with event types data, loading state, error, and refetch function
*
* @example
* ```tsx
* const { data: eventTypes, isLoading, refetch, isRefetching } = useEventTypes();
*
* // Pull-to-refresh
* <RefreshControl refreshing={isRefetching} onRefresh={refetch} />
* ```
*/
export function useEventTypes() {
return useQuery({
queryKey: queryKeys.eventTypes.lists(),
queryFn: () => CalComAPIService.getEventTypes(),
staleTime: CACHE_CONFIG.eventTypes.staleTime,
// Keep previous data while fetching new data (smoother UX)
placeholderData: (previousData) => previousData,
// Don't retry on network errors (keeps cache intact)
retry: (failureCount, error) => {
if (error?.message?.includes("Network") || error?.message?.includes("fetch")) {
return false;
}
return failureCount < 2;
},
refetchOnReconnect: true,
});
}
/**
* Hook to fetch a single event type by ID
*
* @param id - The ID of the event type
* @returns Query result with event type data
*
* @example
* ```tsx
* const { data: eventType, isLoading } = useEventTypeById(123);
* ```
*/
export function useEventTypeById(id: number | undefined) {
return useQuery({
queryKey: queryKeys.eventTypes.detail(id || 0),
queryFn: () => CalComAPIService.getEventTypeById(id!),
enabled: !!id, // Only fetch when id is provided
staleTime: CACHE_CONFIG.eventTypes.staleTime,
});
}
/**
* Hook to create a new event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: createEventType, isPending } = useCreateEventType();
*
* createEventType({
* title: 'Quick Chat',
* slug: 'quick-chat',
* lengthInMinutes: 15,
* });
* ```
*/
export function useCreateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateEventTypeInput) => CalComAPIService.createEventType(input),
onSuccess: (newEventType) => {
// Invalidate the list to include the new event type
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
// Optionally, add the new event type to cache immediately
queryClient.setQueryData(queryKeys.eventTypes.detail(newEventType.id), newEventType);
},
onError: (error) => {
console.error("Failed to create event type:", error);
},
});
}
/**
* Hook to update an event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: updateEventType, isPending } = useUpdateEventType();
*
* updateEventType({
* id: 123,
* updates: { title: 'Updated Title' }
* });
* ```
*/
export function useUpdateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, updates }: { id: number; updates: Partial<CreateEventTypeInput> }) =>
CalComAPIService.updateEventType(id, updates),
onSuccess: (updatedEventType, variables) => {
// Invalidate the list
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
// Update the specific event type in cache
queryClient.setQueryData(queryKeys.eventTypes.detail(variables.id), updatedEventType);
},
onError: (error) => {
console.error("Failed to update event type:", error);
},
});
}
/**
* Hook to delete an event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: deleteEventType, isPending } = useDeleteEventType();
*
* deleteEventType(123);
* ```
*/
export function useDeleteEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => CalComAPIService.deleteEventType(id),
onMutate: async (deletedId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.eventTypes.lists() });
// Snapshot the previous value
const previousEventTypes = queryClient.getQueryData<EventType[]>(
queryKeys.eventTypes.lists()
);
// Optimistically remove from the list
if (previousEventTypes) {
queryClient.setQueryData(
queryKeys.eventTypes.lists(),
previousEventTypes.filter((et) => et.id !== deletedId)
);
}
return { previousEventTypes };
},
onError: (error, _deletedId, context) => {
// Rollback on error
if (context?.previousEventTypes) {
queryClient.setQueryData(queryKeys.eventTypes.lists(), context.previousEventTypes);
}
console.error("Failed to delete event type:", error);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
},
});
}
/**
* Hook to duplicate an event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: duplicateEventType, isPending } = useDuplicateEventType();
*
* duplicateEventType({
* eventType: existingEventType,
* existingEventTypes: allEventTypes
* });
* ```
*/
export function useDuplicateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
eventType,
existingEventTypes,
}: {
eventType: EventType;
existingEventTypes: EventType[];
}) => {
// Generate a new title and slug for the duplicate
const newTitle = `${eventType.title} (copy)`;
let newSlug = `${eventType.slug}-copy`;
// Check if slug already exists and append a number if needed
let counter = 1;
while (existingEventTypes.some((et) => et.slug === newSlug)) {
newSlug = `${eventType.slug}-copy-${counter}`;
counter++;
}
const duration = eventType.lengthInMinutes ?? eventType.length ?? 15;
return CalComAPIService.createEventType({
title: newTitle,
slug: newSlug,
lengthInMinutes: duration,
description: eventType.description || undefined,
});
},
onSuccess: () => {
// Invalidate the list to include the duplicated event type
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
},
onError: (error) => {
console.error("Failed to duplicate event type:", error);
},
});
}
/**
* Hook to prefetch event types (useful for navigation)
*
* @returns Function to prefetch event types
*/
export function usePrefetchEventTypes() {
const queryClient = useQueryClient();
return () => {
queryClient.prefetchQuery({
queryKey: queryKeys.eventTypes.lists(),
queryFn: () => CalComAPIService.getEventTypes(),
staleTime: CACHE_CONFIG.eventTypes.staleTime,
});
};
}
/**
* Hook to invalidate all event types cache
*
* @returns Function to invalidate event types cache
*/
export function useInvalidateEventTypes() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.all });
};
}
/**
* Type exports for consumers
*/
export type { EventType, CreateEventTypeInput };
+327
View File
@@ -0,0 +1,327 @@
/**
* Schedules (Availability) Query Hooks
*
* This module provides React Query hooks for fetching and mutating schedules.
* It integrates with the existing CalComAPIService and provides:
* - Automatic caching with configurable stale times
* - Pull-to-refresh support via refetch
* - Optimistic updates for mutations
* - Cache invalidation on create/update/delete
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CalComAPIService, Schedule } from "../services/calcom";
import { CACHE_CONFIG, queryKeys } from "../config/cache.config";
/**
* Sort schedules: default first, then alphabetically by name
*/
function sortSchedules(schedules: Schedule[]): Schedule[] {
return schedules.sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
return a.name.localeCompare(b.name);
});
}
/**
* Schedule creation input type
*/
export interface CreateScheduleInput {
name: string;
timeZone: string;
isDefault?: boolean;
availability?: Array<{
days: string[];
startTime: string;
endTime: string;
}>;
overrides?: Array<{
date: string;
startTime: string;
endTime: string;
}>;
}
/**
* Schedule update input type
*/
export interface UpdateScheduleInput {
isDefault?: boolean;
name?: string;
timeZone?: string;
availability?: Array<{
days: string[];
startTime: string;
endTime: string;
}>;
overrides?: Array<{
date: string;
startTime: string;
endTime: string;
}>;
}
/**
* Hook to fetch all schedules
*
* @returns Query result with schedules data, loading state, error, and refetch function
*
* @example
* ```tsx
* const { data: schedules, isLoading, refetch, isRefetching } = useSchedules();
*
* // Pull-to-refresh
* <RefreshControl refreshing={isRefetching} onRefresh={refetch} />
* ```
*/
export function useSchedules() {
return useQuery({
queryKey: queryKeys.schedules.lists(),
queryFn: async () => {
const schedules = await CalComAPIService.getSchedules();
return sortSchedules(schedules);
},
staleTime: CACHE_CONFIG.schedules.staleTime,
// Keep previous data while fetching new data (smoother UX)
placeholderData: (previousData) => previousData,
// Don't retry on network errors (keeps cache intact)
retry: (failureCount, error) => {
if (error?.message?.includes("Network") || error?.message?.includes("fetch")) {
return false;
}
return failureCount < 2;
},
refetchOnReconnect: true,
});
}
/**
* Hook to fetch a single schedule by ID
*
* @param id - The ID of the schedule
* @returns Query result with schedule data
*
* @example
* ```tsx
* const { data: schedule, isLoading } = useScheduleById(123);
* ```
*/
export function useScheduleById(id: number | undefined) {
return useQuery({
queryKey: queryKeys.schedules.detail(id || 0),
queryFn: () => CalComAPIService.getScheduleById(id!),
enabled: !!id, // Only fetch when id is provided
staleTime: CACHE_CONFIG.schedules.staleTime,
});
}
/**
* Hook to create a new schedule
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: createSchedule, isPending } = useCreateSchedule();
*
* createSchedule({
* name: 'Working Hours',
* timeZone: 'America/New_York',
* availability: [
* { days: ['Monday', 'Tuesday'], startTime: '09:00', endTime: '17:00' }
* ]
* });
* ```
*/
export function useCreateSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateScheduleInput) => CalComAPIService.createSchedule(input),
onSuccess: (newSchedule) => {
// Invalidate the list to include the new schedule
queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() });
// Optionally, add the new schedule to cache immediately
queryClient.setQueryData(queryKeys.schedules.detail(newSchedule.id), newSchedule);
},
onError: (error) => {
console.error("Failed to create schedule:", error);
},
});
}
/**
* Hook to update a schedule
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: updateSchedule, isPending } = useUpdateSchedule();
*
* updateSchedule({
* id: 123,
* updates: { name: 'Updated Schedule Name' }
* });
* ```
*/
export function useUpdateSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, updates }: { id: number; updates: UpdateScheduleInput }) =>
CalComAPIService.updateSchedule(id, updates),
onSuccess: (updatedSchedule, variables) => {
// Invalidate the list
queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() });
// Update the specific schedule in cache
queryClient.setQueryData(queryKeys.schedules.detail(variables.id), updatedSchedule);
},
onError: (error) => {
console.error("Failed to update schedule:", error);
},
});
}
/**
* Hook to set a schedule as default
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: setAsDefault, isPending } = useSetScheduleAsDefault();
*
* setAsDefault(123);
* ```
*/
export function useSetScheduleAsDefault() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => CalComAPIService.updateSchedule(id, { isDefault: true }),
onSuccess: () => {
// Invalidate all schedules to update the default flag
queryClient.invalidateQueries({ queryKey: queryKeys.schedules.all });
},
onError: (error) => {
console.error("Failed to set schedule as default:", error);
},
});
}
/**
* Hook to delete a schedule
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: deleteSchedule, isPending } = useDeleteSchedule();
*
* deleteSchedule(123);
* ```
*/
export function useDeleteSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => CalComAPIService.deleteSchedule(id),
onMutate: async (deletedId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.schedules.lists() });
// Snapshot the previous value
const previousSchedules = queryClient.getQueryData<Schedule[]>(queryKeys.schedules.lists());
// Optimistically remove from the list
if (previousSchedules) {
queryClient.setQueryData(
queryKeys.schedules.lists(),
previousSchedules.filter((s) => s.id !== deletedId)
);
}
return { previousSchedules };
},
onError: (error, _deletedId, context) => {
// Rollback on error
if (context?.previousSchedules) {
queryClient.setQueryData(queryKeys.schedules.lists(), context.previousSchedules);
}
console.error("Failed to delete schedule:", error);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() });
},
});
}
/**
* Hook to duplicate a schedule
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: duplicateSchedule, isPending } = useDuplicateSchedule();
*
* duplicateSchedule(123);
* ```
*/
export function useDuplicateSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => CalComAPIService.duplicateSchedule(id),
onSuccess: () => {
// Invalidate the list to include the duplicated schedule
queryClient.invalidateQueries({ queryKey: queryKeys.schedules.lists() });
},
onError: (error) => {
console.error("Failed to duplicate schedule:", error);
},
});
}
/**
* Hook to prefetch schedules (useful for navigation)
*
* @returns Function to prefetch schedules
*/
export function usePrefetchSchedules() {
const queryClient = useQueryClient();
return () => {
queryClient.prefetchQuery({
queryKey: queryKeys.schedules.lists(),
queryFn: async () => {
const schedules = await CalComAPIService.getSchedules();
return sortSchedules(schedules);
},
staleTime: CACHE_CONFIG.schedules.staleTime,
});
};
}
/**
* Hook to invalidate all schedules cache
*
* @returns Function to invalidate schedules cache
*/
export function useInvalidateSchedules() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: queryKeys.schedules.all });
};
}
/**
* Type exports for consumers
*/
export type { Schedule };
+159
View File
@@ -0,0 +1,159 @@
/**
* User Profile Query Hooks
*
* This module provides React Query hooks for fetching and updating user profile.
* It integrates with the existing CalComAPIService and provides:
* - Automatic caching with configurable stale times
* - Profile update mutations with cache invalidation
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CalComAPIService, UserProfile } from "../services/calcom";
import { CACHE_CONFIG, queryKeys } from "../config/cache.config";
/**
* User profile update input type
*/
export interface UpdateUserProfileInput {
email?: string;
name?: string;
timeFormat?: number;
defaultScheduleId?: number;
weekStart?: string;
timeZone?: string;
locale?: string;
avatarUrl?: string;
bio?: string;
metadata?: Record<string, unknown>;
}
/**
* Hook to fetch the current user profile
*
* @returns Query result with user profile data, loading state, error, and refetch function
*
* @example
* ```tsx
* const { data: profile, isLoading } = useUserProfile();
*
* if (profile) {
* console.log(profile.username, profile.email);
* }
* ```
*/
export function useUserProfile() {
return useQuery({
queryKey: queryKeys.userProfile.current(),
queryFn: () => CalComAPIService.getUserProfile(),
staleTime: CACHE_CONFIG.userProfile.staleTime,
// Keep previous data while fetching new data (smoother UX)
placeholderData: (previousData) => previousData,
});
}
/**
* Hook to get the current username
*
* @returns Query result with username
*
* @example
* ```tsx
* const { data: username } = useUsername();
* ```
*/
export function useUsername() {
const { data: profile, ...rest } = useUserProfile();
return {
...rest,
data: profile?.username,
};
}
/**
* Hook to update the user profile
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: updateProfile, isPending } = useUpdateUserProfile();
*
* updateProfile({
* name: 'New Name',
* timeZone: 'America/New_York'
* });
* ```
*/
export function useUpdateUserProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (updates: UpdateUserProfileInput) => CalComAPIService.updateUserProfile(updates),
onMutate: async (newData) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.userProfile.current() });
// Snapshot the previous value
const previousProfile = queryClient.getQueryData<UserProfile>(
queryKeys.userProfile.current()
);
// Optimistically update to the new value
if (previousProfile) {
queryClient.setQueryData(queryKeys.userProfile.current(), {
...previousProfile,
...newData,
});
}
return { previousProfile };
},
onError: (error, _newData, context) => {
// Rollback on error
if (context?.previousProfile) {
queryClient.setQueryData(queryKeys.userProfile.current(), context.previousProfile);
}
console.error("Failed to update user profile:", error);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.current() });
},
});
}
/**
* Hook to prefetch user profile (useful for app initialization)
*
* @returns Function to prefetch user profile
*/
export function usePrefetchUserProfile() {
const queryClient = useQueryClient();
return () => {
queryClient.prefetchQuery({
queryKey: queryKeys.userProfile.current(),
queryFn: () => CalComAPIService.getUserProfile(),
staleTime: CACHE_CONFIG.userProfile.staleTime,
});
};
}
/**
* Hook to invalidate user profile cache
*
* @returns Function to invalidate user profile cache
*/
export function useInvalidateUserProfile() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all });
};
}
/**
* Type exports for consumers
*/
export type { UserProfile };
+11
View File
@@ -0,0 +1,11 @@
/**
* @deprecated Import from '../utils/queryPersister' instead.
* This file is kept for backward compatibility.
*/
export {
createQueryPersister,
clearQueryCache,
getCacheMetadata,
storage,
} from "../utils/queryPersister";
+11
View File
@@ -0,0 +1,11 @@
/**
* @deprecated Import from '../utils/storage' instead.
* This file is kept for backward compatibility.
*/
export {
secureStorage,
generalStorage,
isChromeStorageAvailable,
type StorageAdapter,
} from "../utils/storage";
+114
View File
@@ -10,12 +10,17 @@
"dependencies": {
"@expo/ui": "^0.2.0-beta.7",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.1.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-segmented-control/segmented-control": "^2.5.7",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-persist-client": "^5.62.0",
"@types/react": "~19.1.10",
"@types/react-dom": "~19.1.7",
"base64-js": "^1.5.1",
"expo": "~54.0.0",
"expo-auth-session": "^7.0.9",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.10",
"expo-crypto": "^15.0.7",
"expo-device": "^8.0.9",
@@ -4046,6 +4051,27 @@
}
}
},
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native-community/netinfo": {
"version": "11.4.1",
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.59"
}
},
"node_modules/@react-native-segmented-control/segmented-control": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@react-native-segmented-control/segmented-control/-/segmented-control-2.5.7.tgz",
@@ -4800,6 +4826,62 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-persist-client-core": {
"version": "5.91.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.91.11.tgz",
"integrity": "sha512-NNpRGxQY/nVOdzfs5QbevPjGsUVoEiFwqxxaopLyu6todwtDOCfIOfhXSmpMVXBiCxUn7kqaUB1iwaBKqoAVRQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-persist-client": {
"version": "5.90.14",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.90.14.tgz",
"integrity": "sha512-jTGnr/DBlzV/UYqU+b8bZWECBuqh3Q3g7Ih50IktZkvmwTUsidQhhl2JyknNYVZkl5AgMfPAywNKZLTI82wmlA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-persist-client-core": "5.91.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.90.12",
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -7429,6 +7511,17 @@
"react-native": "*"
}
},
"node_modules/expo-clipboard": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": {
"version": "18.0.10",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz",
@@ -9238,6 +9331,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
@@ -10724,6 +10826,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+5
View File
@@ -18,12 +18,17 @@
"dependencies": {
"@expo/ui": "^0.2.0-beta.7",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.1.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-segmented-control/segmented-control": "^2.5.7",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-persist-client": "^5.62.0",
"@types/react": "~19.1.10",
"@types/react-dom": "~19.1.7",
"base64-js": "^1.5.1",
"expo": "~54.0.0",
"expo-auth-session": "^7.0.9",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.10",
"expo-crypto": "^15.0.7",
"expo-device": "^8.0.9",
+165 -47
View File
@@ -309,10 +309,8 @@ export class CalComAPIService {
return response.json();
}
// Delete an event type
static async deleteEventType(eventTypeId: number): Promise<void> {
try {
console.log(`Deleting event type with ID: ${eventTypeId}`);
await this.makeRequest(
`/event-types/${eventTypeId}`,
{
@@ -320,17 +318,15 @@ export class CalComAPIService {
},
"2024-06-14"
);
console.log("Delete completed");
} catch (error) {
console.error("Delete API error:", error);
throw error;
}
}
// Create an event type
static async createEventType(input: CreateEventTypeInput): Promise<EventType> {
try {
console.log("Creating event type with input:", JSON.stringify(input, null, 2));
const sanitizedInput = this.sanitizePayload(input as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: EventType }>(
"/event-types",
@@ -340,13 +336,12 @@ export class CalComAPIService {
"Content-Type": "application/json",
"cal-api-version": "2024-06-14",
},
body: JSON.stringify(input),
body: JSON.stringify(sanitizedInput),
},
"2024-06-14"
);
if (response && response.data) {
console.log("Event type created successfully:", response.data);
return response.data;
}
@@ -358,33 +353,38 @@ export class CalComAPIService {
}
// Cancel a booking
static async cancelBooking(bookingUid: string, reason?: string): Promise<void> {
static async cancelBooking(bookingUid: string, cancellationReason?: string): Promise<void> {
try {
const body: { reason?: string } = {};
if (reason) {
body.reason = reason;
const body: { cancellationReason?: string } = {};
if (cancellationReason) {
body.cancellationReason = cancellationReason;
}
await this.makeRequest(`/bookings/${bookingUid}/cancel`, {
method: "POST",
body: JSON.stringify(body),
});
await this.makeRequest(
`/bookings/${bookingUid}/cancel`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"cal-api-version": "2024-08-13",
},
body: JSON.stringify(body),
},
"2024-08-13"
);
} catch (error) {
throw error;
}
}
// Reschedule a booking
static async rescheduleBooking(
bookingUid: string,
input: {
start: string; // ISO 8601 datetime string
start: string;
reschedulingReason?: string;
}
): Promise<Booking> {
try {
console.log(`Rescheduling booking ${bookingUid} to:`, input.start);
const response = await this.makeRequest<{ status: string; data: Booking }>(
`/bookings/${bookingUid}/reschedule`,
{
@@ -399,7 +399,6 @@ export class CalComAPIService {
);
if (response && response.data) {
console.log("Booking rescheduled successfully:", response.data);
return response.data;
}
@@ -410,6 +409,62 @@ export class CalComAPIService {
}
}
static async confirmBooking(bookingUid: string): Promise<Booking> {
try {
const response = await this.makeRequest<{ status: string; data: Booking }>(
`/bookings/${bookingUid}/confirm`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"cal-api-version": "2024-08-13",
},
},
"2024-08-13"
);
if (response && response.data) {
return response.data;
}
throw new Error("Invalid response from confirm booking API");
} catch (error) {
console.error("confirmBooking error:", error);
throw error;
}
}
static async declineBooking(bookingUid: string, reason?: string): Promise<Booking> {
try {
const body: { reason?: string } = {};
if (reason) {
body.reason = reason;
}
const response = await this.makeRequest<{ status: string; data: Booking }>(
`/bookings/${bookingUid}/decline`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"cal-api-version": "2024-08-13",
},
body: JSON.stringify(body),
},
"2024-08-13"
);
if (response && response.data) {
return response.data;
}
throw new Error("Invalid response from decline booking API");
} catch (error) {
console.error("declineBooking error:", error);
throw error;
}
}
static async getEventTypes(): Promise<EventType[]> {
try {
// Get current user to extract username
@@ -422,11 +477,15 @@ export class CalComAPIService {
}
} catch (error) {}
// Build query string with username if available
// Build query string with username and sorting
const params = new URLSearchParams();
if (username) {
params.append("username", username);
}
// Sort by creation date descending (newer first) to match main codebase behavior
// Main codebase uses position: "desc", id: "desc" - since API doesn't expose position,
// we use sortCreatedAt: "desc" for similar behavior (newer event types first)
params.append("sortCreatedAt", "desc");
const queryString = params.toString();
const endpoint = `/event-types${queryString ? `?${queryString}` : ""}`;
@@ -483,12 +542,7 @@ export class CalComAPIService {
},
"2024-08-13"
);
console.log("getBookingByUid raw response:", JSON.stringify(response, null, 2));
if (response && response.data) {
console.log("getBookingByUid booking data:", JSON.stringify(response.data, null, 2));
console.log("getBookingByUid user field:", response.data.user);
console.log("getBookingByUid hosts field:", response.data.hosts);
console.log("getBookingByUid attendees field:", response.data.attendees);
return response.data;
}
throw new Error("Invalid response from get booking API");
@@ -658,7 +712,7 @@ export class CalComAPIService {
}>;
}): Promise<Schedule> {
try {
console.log("Creating schedule with input:", JSON.stringify(input, null, 2));
const sanitizedInput = this.sanitizePayload(input as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: Schedule }>(
"/schedules",
@@ -668,13 +722,12 @@ export class CalComAPIService {
"Content-Type": "application/json",
"cal-api-version": "2024-06-11",
},
body: JSON.stringify(input),
body: JSON.stringify(sanitizedInput),
},
"2024-06-11"
);
if (response && response.data) {
console.log("Schedule created successfully:", response.data);
return response.data;
}
@@ -685,33 +738,26 @@ export class CalComAPIService {
}
}
// Get specific schedule by ID
static async getScheduleById(scheduleId: number): Promise<Schedule | null> {
try {
const response = await this.makeRequest<any>(
`/schedules/${scheduleId}`,
{
headers: {
"cal-api-version": "2024-06-11", // Override version for schedules
"cal-api-version": "2024-06-11",
},
},
"2024-06-11"
);
console.log("getScheduleById raw response:", JSON.stringify(response, null, 2));
if (response && response.data) {
console.log("Returning schedule data:", response.data);
return response.data;
}
// Sometimes the response might be the schedule directly
if (response && response.id) {
console.log("Returning schedule directly:", response);
return response;
}
console.log("No schedule data found in response");
return null;
} catch (error) {
console.error("getScheduleById error:", error);
@@ -767,12 +813,77 @@ export class CalComAPIService {
}
// Update an event type
/**
* Sanitizes a payload before sending to the API.
* - Removes keys with null values for array fields (API expects arrays or field to be omitted)
* - Removes keys with undefined values
* - Recursively sanitizes nested objects
*/
private static sanitizePayload(payload: Record<string, any>): Record<string, any> {
const sanitized: Record<string, any> = {};
// Fields that should NEVER be sent as null - API expects array or omit entirely
const arrayFields = [
"lengthInMinutesOptions",
"multipleDuration",
"locations",
"bookingFields",
"hosts",
"children",
"customInputs",
];
// Fields that can be null (to clear the value)
const nullableFields = [
"description",
"successRedirectUrl",
"slotInterval",
"eventName",
"timeZone",
];
for (const [key, value] of Object.entries(payload)) {
// Skip undefined values
if (value === undefined) continue;
// Handle null values
if (value === null) {
// For array fields, skip entirely (don't send null)
if (arrayFields.includes(key)) {
console.warn(`Skipping null value for array field: ${key}`);
continue;
}
// For nullable fields, allow null
if (nullableFields.includes(key)) {
sanitized[key] = null;
continue;
}
// For other fields, skip null to be safe
console.warn(`Skipping null value for field: ${key}`);
continue;
}
// Recursively sanitize nested objects (but not arrays)
if (typeof value === "object" && !Array.isArray(value)) {
const sanitizedNested = this.sanitizePayload(value);
// Only include if the nested object has values
if (Object.keys(sanitizedNested).length > 0) {
sanitized[key] = sanitizedNested;
}
} else {
sanitized[key] = value;
}
}
return sanitized;
}
static async updateEventType(
eventTypeId: number,
updates: Partial<CreateEventTypeInput>
): Promise<EventType> {
try {
console.log(`Updating event type ${eventTypeId} with:`, JSON.stringify(updates, null, 2));
const sanitizedUpdates = this.sanitizePayload(updates as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: EventType }>(
`/event-types/${eventTypeId}`,
@@ -782,13 +893,12 @@ export class CalComAPIService {
"Content-Type": "application/json",
"cal-api-version": "2024-06-14",
},
body: JSON.stringify(updates),
body: JSON.stringify(sanitizedUpdates),
},
"2024-06-14"
);
if (response && response.data) {
console.log("Event type updated successfully:", response.data);
return response.data;
}
@@ -819,6 +929,8 @@ export class CalComAPIService {
}
): Promise<Schedule> {
try {
// Sanitize the updates to remove null values
const sanitizedUpdates = this.sanitizePayload(updates as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: Schedule }>(
`/schedules/${scheduleId}`,
{
@@ -827,7 +939,7 @@ export class CalComAPIService {
"Content-Type": "application/json",
"cal-api-version": "2024-06-11",
},
body: JSON.stringify(updates),
body: JSON.stringify(sanitizedUpdates),
},
"2024-06-11"
);
@@ -910,12 +1022,13 @@ export class CalComAPIService {
// Create a global webhook
static async createWebhook(input: CreateWebhookInput): Promise<Webhook> {
try {
const sanitizedInput = this.sanitizePayload(input as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: Webhook }>("/webhooks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
body: JSON.stringify(sanitizedInput),
});
if (response && response.data) {
@@ -932,6 +1045,7 @@ export class CalComAPIService {
// Update a global webhook
static async updateWebhook(webhookId: string, updates: UpdateWebhookInput): Promise<Webhook> {
try {
const sanitizedUpdates = this.sanitizePayload(updates as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: Webhook }>(
`/webhooks/${webhookId}`,
{
@@ -939,7 +1053,7 @@ export class CalComAPIService {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updates),
body: JSON.stringify(sanitizedUpdates),
}
);
@@ -990,6 +1104,7 @@ export class CalComAPIService {
input: CreateWebhookInput
): Promise<Webhook> {
try {
const sanitizedInput = this.sanitizePayload(input as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: Webhook }>(
`/event-types/${eventTypeId}/webhooks`,
{
@@ -997,7 +1112,7 @@ export class CalComAPIService {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
body: JSON.stringify(sanitizedInput),
}
);
@@ -1019,6 +1134,7 @@ export class CalComAPIService {
updates: UpdateWebhookInput
): Promise<Webhook> {
try {
const sanitizedUpdates = this.sanitizePayload(updates as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: Webhook }>(
`/event-types/${eventTypeId}/webhooks/${webhookId}`,
{
@@ -1026,7 +1142,7 @@ export class CalComAPIService {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updates),
body: JSON.stringify(sanitizedUpdates),
}
);
@@ -1081,6 +1197,7 @@ export class CalComAPIService {
input: CreatePrivateLinkInput = {}
): Promise<PrivateLink> {
try {
const sanitizedInput = this.sanitizePayload(input as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: PrivateLink }>(
`/event-types/${eventTypeId}/private-links`,
{
@@ -1088,7 +1205,7 @@ export class CalComAPIService {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
body: JSON.stringify(sanitizedInput),
}
);
@@ -1110,6 +1227,7 @@ export class CalComAPIService {
updates: UpdatePrivateLinkInput
): Promise<PrivateLink> {
try {
const sanitizedUpdates = this.sanitizePayload(updates as Record<string, any>);
const response = await this.makeRequest<{ status: string; data: PrivateLink }>(
`/event-types/${eventTypeId}/private-links/${linkId}`,
{
@@ -1117,7 +1235,7 @@ export class CalComAPIService {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updates),
body: JSON.stringify(sanitizedUpdates),
}
);
+116 -326
View File
@@ -5,7 +5,6 @@ import * as Crypto from "expo-crypto";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
// Complete warm up for WebBrowser on mobile
WebBrowser.maybeCompleteAuthSession();
export interface OAuthTokens {
@@ -31,11 +30,7 @@ export class CalComOAuthService {
this.config = config;
}
private async generatePKCEParams(): Promise<{
codeVerifier: string;
codeChallenge: string;
state: string;
}> {
private async generatePKCEParams() {
const codeVerifier = this.generateRandomBase64Url();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
const state = this.generateRandomBase64Url();
@@ -47,18 +42,13 @@ export class CalComOAuthService {
}
private async generateCodeChallenge(codeVerifier: string): Promise<string> {
try {
const base64Hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
codeVerifier,
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
const base64Hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
codeVerifier,
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
return base64Hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
} catch (error) {
console.error("Failed to generate code challenge:", error);
throw new Error("Failed to generate OAuth code challenge");
}
return base64Hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
private generateRandomBase64Url(): string {
@@ -75,7 +65,7 @@ export class CalComOAuthService {
client_id: this.config.clientId,
response_type: "code",
redirect_uri: this.config.redirectUri,
state: state,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
@@ -83,338 +73,147 @@ export class CalComOAuthService {
return `${this.config.calcomBaseUrl}/auth/oauth2/authorize?${params.toString()}`;
}
async startAuthorizationFlow(): Promise<OAuthTokens> {
try {
const { codeChallenge, state } = await this.generatePKCEParams();
// Web only: check for stored callback with state-specific keys to prevent race conditions
if (Platform.OS === "web" && typeof window !== "undefined") {
const storedCode = window.localStorage.getItem(`oauth_callback_code_${state}`);
const storedState = window.localStorage.getItem(`oauth_callback_state_${state}`);
if (storedCode && storedState) {
// CSRF protection: verify state matches
if (storedState !== state) {
window.localStorage.removeItem(`oauth_callback_code_${state}`);
window.localStorage.removeItem(`oauth_callback_state_${state}`);
throw new Error("Invalid state parameter - possible CSRF attack");
}
window.localStorage.removeItem(`oauth_callback_code_${state}`);
window.localStorage.removeItem(`oauth_callback_state_${state}`);
return await this.exchangeCodeForTokens(storedCode, state);
}
}
// Note: State stored in background script (iframe may not have chrome.storage access)
const authResult = await this.getAuthorizationResult(codeChallenge, state);
if (authResult.type === "success") {
const code = authResult.params?.code || authResult.params?.authorizationCode;
const returnedState = authResult.params?.state;
if (returnedState !== state) {
throw new Error("Invalid state parameter - possible CSRF attack");
}
if (!code) {
throw new Error("No authorization code received");
}
return await this.exchangeCodeForTokens(code, state);
}
if (authResult.type === "error") {
const errorDescription =
authResult.params?.error_description ||
("error" in authResult ? authResult.error?.message : undefined) ||
"Unknown error";
throw new Error(`OAuth error: ${errorDescription}`);
}
if (authResult.type === "cancel") {
throw new Error("OAuth flow was cancelled by user");
}
throw new Error("OAuth flow failed or was dismissed");
} catch (error) {
console.error("OAuth authorization error:", error);
throw error;
}
}
// Detect if this is a mobile app (not web/extension)
private isMobileApp(): boolean {
return Platform.OS !== "web";
}
private async launchExtensionAuthFlow(authUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
if (typeof chrome !== "undefined" && chrome.identity) {
chrome.identity.launchWebAuthFlow(
{
url: authUrl,
interactive: true,
},
(responseUrl) => {
if (chrome.runtime.lastError) {
reject(new Error(`OAuth flow failed: ${chrome.runtime.lastError.message}`));
} else if (responseUrl) {
resolve(responseUrl);
} else {
reject(new Error("OAuth flow cancelled or failed"));
}
}
);
return;
async startAuthorizationFlow(): Promise<OAuthTokens> {
const { codeChallenge, state } = await this.generatePKCEParams();
if (Platform.OS === "web" && typeof window !== "undefined") {
const storedCode = window.localStorage.getItem(`oauth_callback_code_${state}`);
const storedState = window.localStorage.getItem(`oauth_callback_state_${state}`);
if (storedCode && storedState) {
if (storedState !== state) {
throw new Error("Invalid state parameter");
}
window.localStorage.removeItem(`oauth_callback_code_${state}`);
window.localStorage.removeItem(`oauth_callback_state_${state}`);
return this.exchangeCodeForTokens(storedCode, state);
}
}
// Iframe: communicate with parent window
if (window.parent !== window) {
let timeoutId: NodeJS.Timeout | null = null;
const result = await this.getAuthorizationResult(codeChallenge, state);
const messageHandler = (event: MessageEvent) => {
// Security: only accept messages from parent
if (event.source !== window.parent) {
return;
}
if (result.type !== "success") {
throw new Error("OAuth flow failed");
}
if (event.data.type === "cal-extension-oauth-result") {
window.removeEventListener("message", messageHandler);
if (timeoutId) {
clearTimeout(timeoutId);
}
const code = result.params.code;
const returnedState = result.params.state;
if (event.data.success) {
resolve(event.data.responseUrl);
} else {
console.error("OAuth flow failed:", event.data.error);
reject(new Error(event.data.error || "OAuth flow failed"));
}
}
};
if (!code) {
throw new Error("No authorization code received");
}
window.addEventListener("message", messageHandler);
if (returnedState !== state) {
throw new Error("Invalid state parameter");
}
window.parent.postMessage(
{
type: "cal-extension-oauth-request",
authUrl: authUrl,
},
"*"
);
timeoutId = setTimeout(() => {
console.error("OAuth flow timeout - no response from extension");
window.removeEventListener("message", messageHandler);
reject(new Error("OAuth flow timeout - no response from extension"));
}, 30000);
} else {
reject(new Error("Chrome extension context not detected"));
}
});
return this.exchangeCodeForTokens(code, state);
}
private async getAuthorizationResult(
codeChallenge: string,
state: string
): Promise<AuthSession.AuthSessionResult | { type: string; params: Record<string, string> }> {
): Promise<{ type: "success"; params: Record<string, string> } | { type: "error" }> {
const authUrl = this.buildAuthorizationUrl(codeChallenge, state);
if (this.isMobileApp()) {
const result = await WebBrowser.openAuthSessionAsync(authUrl, this.config.redirectUri);
const result = await WebBrowser.openAuthSessionAsync(authUrl, this.config.redirectUri, {
preferEphemeralSession: false,
});
if (result.type === "success") {
const params = this.parseCallbackUrl(result.url);
return { type: "success" as const, params };
return { type: "success", params: this.parseCallbackUrl(result.url) };
}
return { type: result.type, params: {} } as { type: string; params: Record<string, string> };
} else {
// Treat everything else as browser extension
try {
const responseUrl = await this.launchExtensionAuthFlow(authUrl);
const params = this.parseCallbackUrl(responseUrl);
return { type: "success" as const, params };
} catch (error) {
console.error("Extension OAuth flow failed:", error);
return { type: "error", params: { error: error.message } } as {
type: string;
params: Record<string, string>;
};
return { type: "error" };
}
if (Platform.OS === "web") {
const discovery = await this.getDiscoveryEndpoints();
const request = new AuthSession.AuthRequest({
clientId: this.config.clientId,
redirectUri: this.config.redirectUri,
responseType: AuthSession.ResponseType.Code,
state,
codeChallenge,
codeChallengeMethod: AuthSession.CodeChallengeMethod.S256,
});
const result = await request.promptAsync(discovery);
if (result.type === "success") {
return { type: "success", params: result.params ?? {} };
}
return { type: "error" };
}
try {
const responseUrl = await this.launchExtensionAuthFlow(authUrl);
return { type: "success", params: this.parseCallbackUrl(responseUrl) };
} catch {
return { type: "error" };
}
}
private async launchExtensionAuthFlow(authUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
if (typeof chrome !== "undefined" && chrome.identity) {
chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (responseUrl) {
resolve(responseUrl);
} else {
reject(new Error("OAuth cancelled"));
}
});
return;
}
reject(new Error("Extension context not available"));
});
}
private async getDiscoveryEndpoints(): Promise<AuthSession.DiscoveryDocument> {
const fallbackDiscovery: AuthSession.DiscoveryDocument = {
return {
authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`,
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
revocationEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/revoke`,
};
const isCrossOriginWeb =
Platform.OS === "web" &&
typeof window !== "undefined" &&
(() => {
try {
return new URL(this.config.calcomBaseUrl).origin !== window.location.origin;
} catch {
return true;
}
})();
// Skip discovery fetch when we know CORS will block it (e.g. companion.cal.com -> app.cal.com).
if (isCrossOriginWeb) {
return fallbackDiscovery;
}
try {
const discovery = await AuthSession.fetchDiscoveryAsync(this.config.calcomBaseUrl);
return {
...fallbackDiscovery,
...discovery,
};
} catch (error) {
console.warn("Failed to load discovery document, using fallback endpoints", error);
return fallbackDiscovery;
}
}
private parseCallbackUrl(url: string): Record<string, string> {
const urlObj = new URL(url);
const parsed = new URL(url);
const params: Record<string, string> = {};
urlObj.searchParams.forEach((value, key) => {
params[key] = value;
parsed.searchParams.forEach((v, k) => {
params[k] = v;
});
if (Object.keys(params).length === 0 && urlObj.hash) {
const hashParams = new URLSearchParams(urlObj.hash.substring(1));
hashParams.forEach((value, key) => {
params[key] = value;
});
}
return params;
}
private async exchangeCodeForTokens(code: string, state?: string): Promise<OAuthTokens> {
private async exchangeCodeForTokens(code: string): Promise<OAuthTokens> {
if (!this.codeVerifier) {
throw new Error("No code verifier available");
throw new Error("Missing code verifier");
}
// Extension: use APIs to avoid CORS
if (!this.isMobileApp() && typeof window !== "undefined" && window.parent !== window) {
return await this.exchangeTokensViaExtension(code, state);
}
const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/token`;
const body = new URLSearchParams();
body.append("grant_type", "authorization_code");
body.append("client_id", this.config.clientId);
body.append("code", code);
body.append("redirect_uri", this.config.redirectUri);
body.append("code_verifier", this.codeVerifier);
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const errorData = await response.text();
console.error("Token exchange error response:", errorData);
try {
const errorJson = JSON.parse(errorData);
console.error("Parsed error:", errorJson);
} catch {
console.error("Could not parse error response as JSON");
}
throw new Error(`Token exchange failed: ${response.status} ${errorData}`);
}
const tokenData = await response.json();
const tokens: OAuthTokens = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type || "Bearer",
expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
scope: tokenData.scope,
};
return tokens;
}
private async exchangeTokensViaExtension(code: string, state?: string): Promise<OAuthTokens> {
return new Promise((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null;
const messageHandler = (event: MessageEvent) => {
// Security: only accept messages from parent
if (event.source !== window.parent) {
return;
}
if (event.data.type === "cal-extension-token-exchange-result") {
window.removeEventListener("message", messageHandler);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (event.data.success) {
resolve(event.data.tokens);
} else {
console.error("Token exchange failed via extension:", event.data.error);
reject(new Error(event.data.error || "Token exchange failed"));
}
}
};
window.addEventListener("message", messageHandler);
window.parent.postMessage(
{
type: "cal-extension-token-exchange-request",
tokenRequest: {
grant_type: "authorization_code",
client_id: this.config.clientId,
code: code,
redirect_uri: this.config.redirectUri,
code_verifier: this.codeVerifier,
},
state: state, // CSRF validation in background script
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
},
"*"
);
timeoutId = setTimeout(() => {
window.removeEventListener("message", messageHandler);
reject(new Error("Token exchange timeout"));
}, 30000);
});
}
async refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/refreshToken`;
const body = new URLSearchParams({
grant_type: "refresh_token",
grant_type: "authorization_code",
client_id: this.config.clientId,
refresh_token: refreshToken,
code,
redirect_uri: this.config.redirectUri,
code_verifier: this.codeVerifier,
});
const response = await fetch(tokenEndpoint, {
const response = await fetch(`${this.config.calcomBaseUrl}/api/auth/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -424,51 +223,42 @@ export class CalComOAuthService {
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${errorData}`);
throw new Error("Token exchange failed");
}
const tokenData = await response.json();
const data = await response.json();
const tokens: OAuthTokens = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || refreshToken,
tokenType: tokenData.token_type || "Bearer",
expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
scope: tokenData.scope,
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
tokenType: data.token_type ?? "Bearer",
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
scope: data.scope,
};
return tokens;
}
isTokenExpired(tokens: OAuthTokens): boolean {
if (!tokens.expiresAt) {
return false;
}
// 5-minute buffer before expiry
if (!tokens.expiresAt) return false;
return Date.now() >= tokens.expiresAt - 5 * 60 * 1000;
}
clearPKCEParams(): void {
clearPKCEParams() {
this.codeVerifier = null;
this.state = null;
}
}
export function createCalComOAuthService(overrides: Partial<OAuthConfig> = {}): CalComOAuthService {
let defaultRedirectUri: string;
const defaultConfig: OAuthConfig = {
const config: OAuthConfig = {
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "",
redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI,
redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "",
calcomBaseUrl: "https://app.cal.com",
...overrides,
};
if (!defaultConfig.clientId) {
throw new Error(
"OAuth client ID is required. Set EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID environment variable."
);
if (!config.clientId || !config.redirectUri) {
throw new Error("OAuth configuration incomplete");
}
return new CalComOAuthService(defaultConfig);
return new CalComOAuthService(config);
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Central export for all custom types
*/
export * from "./locations";
+94
View File
@@ -0,0 +1,94 @@
/**
* Location types for event type management
* These types are used across the companion app for handling event type locations
*/
/**
* Valid location types supported by the Cal.com API
*/
export type LocationType =
| "integration"
| "address"
| "link"
| "phone"
| "attendeeAddress"
| "attendeePhone"
| "attendeeDefined";
/**
* Valid integration types for conferencing apps
*/
export type IntegrationType =
| "cal-video"
| "google-meet"
| "zoom"
| "office365-video"
| "msteams"
| "webex"
| "jitsi";
/**
* Represents a location item in the UI
*/
export interface LocationItem {
/** Unique identifier for the location (used for list rendering) */
id: string;
/** The type of location */
type: LocationType;
/** Integration app ID (for conferencing apps) */
integration?: string;
/** Physical address (for address type) */
address?: string;
/** Meeting link URL (for link type) */
link?: string;
/** Phone number (for phone types) */
phone?: string;
/** Whether the location is public */
public?: boolean;
/** Display name shown in the UI */
displayName: string;
/** Icon URL for the location */
iconUrl: string | null;
}
/**
* Location object as returned from the API
*/
export interface ApiLocation {
type: string;
integration?: string;
address?: string;
link?: string;
phone?: string;
public?: boolean;
}
/**
* Location input for API requests
*/
export interface ApiLocationInput {
type: string;
integration?: string;
address?: string;
link?: string;
phone?: string;
public?: boolean;
}
/**
* Location option for dropdown selection
*/
export interface LocationOption {
label: string;
value: string;
iconUrl: string | null;
category?: string;
}
/**
* Grouped location options for dropdown
*/
export interface LocationOptionGroup {
category: string;
options: LocationOption[];
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Alert Utilities
*
* Helper functions for showing alerts with environment-aware behavior.
* Error alerts are only shown in development mode to avoid confusing users in production.
*/
import { Alert } from "react-native";
/**
* Show an error alert only in development mode.
* In production, errors are silently logged to console.
*
* @param title - The alert title
* @param message - The error message to display
*/
export const showErrorAlert = (title: string, message: string) => {
if (__DEV__) {
Alert.alert(title, message);
} else {
console.error(`[${title}] ${message}`);
}
};
/**
* Show a confirmation alert (always shown - user-initiated actions)
* This is a direct reference to Alert.alert for consistency.
*/
export const showConfirmAlert = Alert.alert;
/**
* Show a success alert (always shown - user feedback)
*
* @param title - The alert title
* @param message - The success message to display
*/
export const showSuccessAlert = (title: string, message: string) => {
Alert.alert(title, message);
};
+63
View File
@@ -0,0 +1,63 @@
/**
* Browser Utilities
*
* Centralized utility for opening links in the in-app browser.
* Configured for session sharing with Safari/Chrome to maintain login state.
*/
import * as WebBrowser from "expo-web-browser";
import { showErrorAlert } from "./alerts";
/**
* Configuration options for in-app browser
*/
export interface BrowserOptions {
/** iOS: Toolbar color (hex string) */
toolbarColor?: string;
/** iOS: Controls color (hex string) */
controlsColor?: string;
}
/**
* Open a URL in the in-app browser with session sharing enabled.
*
* Session sharing allows cookies to be shared between the in-app browser
* and Safari (iOS) or Chrome (Android). This means users who authenticate
* via OAuth will remain logged in when opening Cal.com links.
*
* @param url - The URL to open
* @param fallbackMessage - Optional message to show in error alert (defaults to "link")
* @param options - Optional browser customization options
*
* @example
* ```tsx
* // Open a link with session sharing
* await openInAppBrowser("https://app.cal.com");
*
* // With custom error message
* await openInAppBrowser("https://app.cal.com/settings", "Settings page");
*
* // With custom toolbar color
* await openInAppBrowser("https://app.cal.com", "Cal.com", { toolbarColor: "#111827" });
* ```
*/
export const openInAppBrowser = async (
url: string,
fallbackMessage?: string,
options?: BrowserOptions
): Promise<void> => {
try {
// Configure browser options
// Session sharing happens automatically when using Safari View Controller (iOS)
// or Chrome Custom Tabs (Android) - no special configuration needed
const browserOptions: WebBrowser.WebBrowserOpenOptions = {
...(options?.toolbarColor && { toolbarColor: options.toolbarColor }),
...(options?.controlsColor && { controlsColor: options.controlsColor }),
};
await WebBrowser.openBrowserAsync(url, browserOptions);
} catch (error) {
console.error(`Failed to open ${url}:`, error);
showErrorAlert("Error", `Failed to open ${fallbackMessage || "link"}. Please try again.`);
}
};
@@ -1,12 +1,24 @@
/**
* Helper functions to parse form values into API-compatible formats
* Used for event type form handling
*/
/**
* Parse buffer time string to minutes
* @param buffer - Buffer time string (e.g., "15 minutes", "30")
* @returns Number of minutes
*/
export const parseBufferTime = (buffer: string): number => {
const match = buffer.match(/(\d+)/);
return match ? parseInt(match[1]) : 0;
};
/**
* Parse minimum notice value and unit to total minutes
* @param value - Numeric value as string
* @param unit - Unit ("Minutes", "Hours", "Days")
* @returns Total minutes
*/
export const parseMinimumNotice = (value: string, unit: string): number => {
const val = parseInt(value) || 0;
if (unit === "Hours") return val * 60;
@@ -14,6 +26,11 @@ export const parseMinimumNotice = (value: string, unit: string): number => {
return val; // Minutes
};
/**
* Parse frequency unit string to API format
* @param unit - Unit string (e.g., "Weeks", "Monthly")
* @returns Normalized unit ("day", "week", "month", "year") or null
*/
export const parseFrequencyUnit = (unit: string): string | null => {
const normalized = unit.toLowerCase();
if (normalized.includes("day")) return "day";
@@ -23,6 +40,11 @@ export const parseFrequencyUnit = (unit: string): string | null => {
return null;
};
/**
* Parse slot interval string to minutes
* @param interval - Interval string (e.g., "15 minutes", "30")
* @returns Number of minutes
*/
export const parseSlotInterval = (interval: string): number => {
const match = interval.match(/(\d+)/);
return match ? parseInt(match[1]) : 0;
+54
View File
@@ -0,0 +1,54 @@
/**
* Generic formatting utilities
* These are reusable formatting functions used across the app
*/
/**
* Format duration in minutes to a human-readable string
* @param minutes - Duration in minutes (number or string)
* @returns Formatted string like "30m", "1h", "1h 30m"
*
* @example
* formatDuration(30) // "30m"
* formatDuration(60) // "1h"
* formatDuration(90) // "1h 30m"
*/
export const formatDuration = (minutes: number | string | undefined): string => {
const mins = typeof minutes === "string" ? parseInt(minutes) || 0 : minutes || 0;
if (mins <= 0) return "0m";
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
};
/**
* Truncate text to a maximum length with ellipsis
* @param text - The text to truncate
* @param maxLength - Maximum length (default: 20)
* @returns Truncated text with "..." if it exceeds maxLength
*
* @example
* truncateTitle("Very long title here", 10) // "Very long..."
*/
export const truncateTitle = (text: string, maxLength: number = 20): string => {
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
/**
* Format an app ID to a display name
* Converts kebab-case to Title Case
*
* @param appId - The app identifier (e.g., "google-meet", "cal-video")
* @returns Formatted display name (e.g., "Google Meet", "Cal Video")
*
* @example
* formatAppIdToDisplayName("google-meet") // "Google Meet"
* formatAppIdToDisplayName("cal-video") // "Cal Video"
*/
export const formatAppIdToDisplayName = (appId: string): string => {
return appId
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
+82
View File
@@ -0,0 +1,82 @@
/**
* Utils Index
*
* Central export point for all utility functions.
* Import utilities from this file for clean imports:
*
* @example
* ```tsx
* import { formatDuration, secureStorage, parseBufferTime } from '../utils';
* ```
*/
// Formatting utilities
export { formatDuration, truncateTitle, formatAppIdToDisplayName } from "./formatters";
// Storage utilities
export {
secureStorage,
generalStorage,
isChromeStorageAvailable,
type StorageAdapter,
} from "./storage";
// Query persistence utilities
export { createQueryPersister, clearQueryCache, getCacheMetadata } from "./queryPersister";
// Alert utilities
export { showErrorAlert } from "./alerts";
// Browser utilities
export { openInAppBrowser } from "./browser";
// Network utilities
export { isOnline, subscribeToNetworkChanges } from "./network";
// Slug utilities
export { slugify } from "./slugify";
// App icon utilities
export { getAppIconUrl } from "./getAppIconUrl";
// Default location utilities
export {
defaultLocations,
getDefaultLocationIconUrl,
isDefaultLocation,
DefaultLocationType,
type DefaultLocation,
} from "./defaultLocations";
// Location helper utilities
export {
formatAppIdToDisplayName as formatAppId, // Alias for backward compatibility
generateLocationId,
mapApiLocationToItem,
mapItemToApiLocation,
getLocationDisplayName,
getLocationIconUrl,
createLocationItemFromOption,
locationRequiresInput,
getLocationInputType,
getLocationInputPlaceholder,
getLocationInputLabel,
validateLocationItem,
buildLocationOptions,
displayNameToLocationValue,
} from "./locationHelpers";
// Event type parser utilities
export {
parseBufferTime,
parseMinimumNotice,
parseFrequencyUnit,
parseSlotInterval,
} from "./eventTypeParsers";
// Gmail Google Chip parser (for extension)
export {
parseGoogleChip,
type GoogleTimeSlot,
type ParsedGoogleChip,
} from "./gmailGoogleChipParser";
+516
View File
@@ -0,0 +1,516 @@
/**
* Helper functions for handling event type locations
* Provides utilities for converting between API format and UI format
*/
import { getAppIconUrl } from "./getAppIconUrl";
import {
defaultLocations,
getDefaultLocationIconUrl,
DefaultLocationType,
} from "./defaultLocations";
import { formatAppIdToDisplayName } from "./formatters";
import {
LocationItem,
ApiLocation,
ApiLocationInput,
LocationOption,
LocationOptionGroup,
} from "../types/locations";
// Re-export formatAppIdToDisplayName for backward compatibility
export { formatAppIdToDisplayName } from "./formatters";
/**
* Generate a unique ID for a location item
*/
export function generateLocationId(): string {
return `loc_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Map an API location response to a UI LocationItem
*/
export function mapApiLocationToItem(apiLocation: ApiLocation): LocationItem {
const id = generateLocationId();
// Handle integration type (conferencing apps)
if (apiLocation.type === "integration" && apiLocation.integration) {
const iconUrl = getAppIconUrl("", apiLocation.integration);
return {
id,
type: "integration",
integration: apiLocation.integration,
displayName: formatAppIdToDisplayName(apiLocation.integration),
iconUrl,
public: apiLocation.public,
};
}
// Handle address type
if (apiLocation.type === "address") {
return {
id,
type: "address",
address: apiLocation.address || "",
displayName: "In Person (Organizer Address)",
iconUrl: "https://app.cal.com/map-pin-dark.svg",
public: apiLocation.public,
};
}
// Handle attendeeAddress type
if (apiLocation.type === "attendeeAddress") {
return {
id,
type: "attendeeAddress",
displayName: "In Person (Attendee Address)",
iconUrl: "https://app.cal.com/map-pin-dark.svg",
};
}
// Handle link type
if (apiLocation.type === "link") {
return {
id,
type: "link",
link: apiLocation.link || "",
displayName: "Link Meeting",
iconUrl: "https://app.cal.com/link.svg",
public: apiLocation.public,
};
}
// Handle phone type (organizer phone)
if (apiLocation.type === "phone") {
return {
id,
type: "phone",
phone: apiLocation.phone || "",
displayName: "Organizer Phone Number",
iconUrl: "https://app.cal.com/phone.svg",
public: apiLocation.public,
};
}
// Handle attendeePhone type
if (apiLocation.type === "attendeePhone") {
return {
id,
type: "attendeePhone",
displayName: "Attendee Phone Number",
iconUrl: "https://app.cal.com/phone.svg",
};
}
// Handle attendeeDefined type
if (apiLocation.type === "attendeeDefined") {
return {
id,
type: "attendeeDefined",
displayName: "Custom Attendee Location",
iconUrl: "https://app.cal.com/message-pin.svg",
};
}
// Fallback for unknown types
return {
id,
type: apiLocation.type as LocationItem["type"],
displayName: apiLocation.type,
iconUrl: null,
};
}
/**
* Map a UI LocationItem to API location format for saving
*/
export function mapItemToApiLocation(item: LocationItem): ApiLocationInput {
// Handle integration type
if (item.type === "integration" && item.integration) {
return {
type: "integration",
integration: item.integration,
};
}
// Handle address type
if (item.type === "address") {
return {
type: "address",
address: item.address || "",
public: item.public ?? true,
};
}
// Handle attendeeAddress type
if (item.type === "attendeeAddress") {
return {
type: "attendeeAddress",
};
}
// Handle link type
if (item.type === "link") {
return {
type: "link",
link: item.link || "",
public: item.public ?? true,
};
}
// Handle phone type (organizer phone)
if (item.type === "phone") {
return {
type: "phone",
phone: item.phone || "",
public: item.public ?? true,
};
}
// Handle attendeePhone type
if (item.type === "attendeePhone") {
return {
type: "attendeePhone",
};
}
// Handle attendeeDefined type
if (item.type === "attendeeDefined") {
return {
type: "attendeeDefined",
};
}
// Fallback
return {
type: item.type,
};
}
/**
* Get the display name for a location type
*/
export function getLocationDisplayName(locationType: string, integration?: string): string {
// Handle integration type
if (locationType === "integration" && integration) {
return formatAppIdToDisplayName(integration);
}
// Check default locations
const typeToDisplayName: Record<string, string> = {
address: "In Person (Organizer Address)",
attendeeAddress: "In Person (Attendee Address)",
link: "Link Meeting",
phone: "Organizer Phone Number",
attendeePhone: "Attendee Phone Number",
attendeeDefined: "Custom Attendee Location",
};
return typeToDisplayName[locationType] || locationType;
}
/**
* Get the icon URL for a location type
*/
export function getLocationIconUrl(locationType: string, integration?: string): string | null {
// Handle integration type
if (locationType === "integration" && integration) {
return getAppIconUrl("", integration);
}
// Check default location icons
const typeToIcon: Record<string, string> = {
address: "https://app.cal.com/map-pin-dark.svg",
attendeeAddress: "https://app.cal.com/map-pin-dark.svg",
link: "https://app.cal.com/link.svg",
phone: "https://app.cal.com/phone.svg",
attendeePhone: "https://app.cal.com/phone.svg",
attendeeDefined: "https://app.cal.com/message-pin.svg",
};
return typeToIcon[locationType] || null;
}
/**
* Create a new LocationItem from a location option selection
*/
export function createLocationItemFromOption(
optionValue: string,
optionLabel: string
): LocationItem {
const id = generateLocationId();
// Handle integration options (format: "integrations:app-id")
if (optionValue.startsWith("integrations:")) {
const integration = optionValue.replace("integrations:", "");
return {
id,
type: "integration",
integration,
displayName: optionLabel,
iconUrl: getAppIconUrl("", integration),
};
}
// Handle default location types
const defaultLocation = defaultLocations.find((loc) => loc.type === optionValue);
if (defaultLocation) {
const item: LocationItem = {
id,
type: mapDefaultLocationTypeToApiType(defaultLocation.type),
displayName: defaultLocation.label,
iconUrl: defaultLocation.iconUrl,
};
// Add input fields for specific types
if (defaultLocation.type === DefaultLocationType.InPerson) {
item.address = "";
item.public = true;
} else if (defaultLocation.type === DefaultLocationType.Link) {
item.link = "";
item.public = true;
} else if (defaultLocation.type === DefaultLocationType.UserPhone) {
item.phone = "";
item.public = true;
}
return item;
}
// Fallback
return {
id,
type: optionValue as LocationItem["type"],
displayName: optionLabel,
iconUrl: null,
};
}
/**
* Map DefaultLocationType to API location type
*/
function mapDefaultLocationTypeToApiType(defaultType: string): LocationItem["type"] {
const typeMap: Record<string, LocationItem["type"]> = {
[DefaultLocationType.AttendeeInPerson]: "attendeeAddress",
[DefaultLocationType.InPerson]: "address",
[DefaultLocationType.Phone]: "attendeePhone",
[DefaultLocationType.UserPhone]: "phone",
[DefaultLocationType.Link]: "link",
[DefaultLocationType.SomewhereElse]: "attendeeDefined",
};
return typeMap[defaultType] || (defaultType as LocationItem["type"]);
}
/**
* Check if a location type requires additional input
*/
export function locationRequiresInput(locationType: LocationItem["type"]): boolean {
return ["address", "link", "phone"].includes(locationType);
}
/**
* Get the input field type for a location
*/
export function getLocationInputType(
locationType: LocationItem["type"]
): "text" | "phone" | "url" | null {
switch (locationType) {
case "address":
return "text";
case "link":
return "url";
case "phone":
return "phone";
default:
return null;
}
}
/**
* Get the input placeholder for a location type
*/
export function getLocationInputPlaceholder(locationType: LocationItem["type"]): string {
switch (locationType) {
case "address":
return "Enter address or place";
case "link":
return "https://meet.example.com/join/123456";
case "phone":
return "Enter phone number";
default:
return "";
}
}
/**
* Get the input label for a location type
*/
export function getLocationInputLabel(locationType: LocationItem["type"]): string {
switch (locationType) {
case "address":
return "Address";
case "link":
return "Meeting Link";
case "phone":
return "Phone Number";
default:
return "";
}
}
/**
* Validate a location item before saving
*/
export function validateLocationItem(item: LocationItem): { valid: boolean; error?: string } {
// Integration types don't need additional validation
if (item.type === "integration") {
if (!item.integration) {
return { valid: false, error: "Integration type is required" };
}
return { valid: true };
}
// Address type requires address field
if (item.type === "address") {
if (!item.address || item.address.trim() === "") {
return { valid: false, error: "Address is required" };
}
return { valid: true };
}
// Link type requires link field
if (item.type === "link") {
if (!item.link || item.link.trim() === "") {
return { valid: false, error: "Meeting link is required" };
}
// URL validation with protocol restriction
try {
const url = new URL(item.link);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return { valid: false, error: "Meeting link must use http or https" };
}
} catch {
return { valid: false, error: "Invalid meeting link URL" };
}
return { valid: true };
}
// Phone type requires phone field
if (item.type === "phone") {
if (!item.phone || item.phone.trim() === "") {
return { valid: false, error: "Phone number is required" };
}
return { valid: true };
}
// Other types (attendeeAddress, attendeePhone, attendeeDefined) don't need input
return { valid: true };
}
/**
* Build location options for dropdown from conferencing options and default locations
*/
export function buildLocationOptions(
conferencingOptions: Array<{ type: string; appId: string }>
): LocationOptionGroup[] {
// Cal Video is always available as the default conferencing app
const calVideoOption: LocationOption = {
label: "Cal Video",
iconUrl: getAppIconUrl("daily_video", "cal-video"),
value: "integrations:cal-video",
category: "conferencing",
};
// Group conferencing apps under "conferencing" category
const conferencingAppOptions: LocationOption[] = conferencingOptions
.filter((option) => option.appId !== "cal-video" && option.type !== "daily_video")
.map((option) => ({
label: formatAppIdToDisplayName(option.appId),
iconUrl: getAppIconUrl(option.type, option.appId),
value: `integrations:${option.appId}`,
category: "conferencing",
}));
// Group default locations by their category
const grouped: Record<string, LocationOption[]> = {
conferencing: [calVideoOption, ...conferencingAppOptions],
};
// Add default locations by category
defaultLocations.forEach((location) => {
const option: LocationOption = {
label: location.label,
iconUrl: location.iconUrl,
value: location.type,
category: location.category,
};
if (!grouped[location.category]) {
grouped[location.category] = [];
}
grouped[location.category].push(option);
});
// Convert to array of groups with proper ordering
const categoryOrder = ["conferencing", "in person", "phone", "other"];
const categoryLabels: Record<string, string> = {
conferencing: "Conferencing",
"in person": "In Person",
phone: "Phone",
other: "Other",
};
return categoryOrder
.filter((category) => grouped[category] && grouped[category].length > 0)
.map((category) => ({
category: categoryLabels[category] || category,
options: grouped[category],
}));
}
/**
* Convert a display name back to a location value for API
* Used when selecting a location from a dropdown
*
* @param displayName - The display name shown in UI (e.g., "Google Meet", "In Person (Organizer Address)")
* @param defaultLocationsList - List of default locations to check against
* @returns Location value object for API, or null if not found
*/
export const displayNameToLocationValue = (
displayName: string,
defaultLocationsList: Array<{ label: string; type: string }>
): {
type: string;
integration?: string;
address?: string;
link?: string;
phone?: string;
public?: boolean;
} | null => {
// First check if it's a default location
const defaultLocation = defaultLocationsList.find((loc) => loc.label === displayName);
if (defaultLocation) {
// Map internal location types to API location types
switch (defaultLocation.type) {
case "attendeeInPerson":
return { type: "attendeeAddress" };
case "inPerson":
return { type: "address", address: "", public: true };
case "link":
return { type: "link", link: "", public: true };
case "phone":
return { type: "attendeePhone" };
case "userPhone":
return { type: "phone", phone: "", public: true };
default:
return null;
}
}
// Check if it's a conferencing app (formatted display name)
// e.g., "Google Meet", "Zoom", etc.
const appId = displayName.toLowerCase().replace(/\s+/g, "-");
return { type: "integration", integration: appId };
};
+47
View File
@@ -0,0 +1,47 @@
/**
* Network Utilities
*
* Helper functions for network-aware operations.
*/
import { Alert } from "react-native";
import NetInfo from "@react-native-community/netinfo";
/**
* Check if the device is currently online
*
* @returns Promise<boolean> - true if online, false if offline
*/
export const isOnline = async (): Promise<boolean> => {
const netState = await NetInfo.fetch();
return netState.isConnected === true && netState.isInternetReachable !== false;
};
/**
* Execute a refresh function only if online.
* Shows a friendly alert if offline and preserves cached data.
*
* @param refetchFn - The refetch function to call (e.g., from React Query)
* @returns Promise<void>
*
* @example
* ```tsx
* const { refetch } = useBookings();
*
* const onRefresh = () => offlineAwareRefresh(refetch);
*
* <RefreshControl onRefresh={onRefresh} />
* ```
*/
export const offlineAwareRefresh = async (refetchFn: () => Promise<unknown>): Promise<void> => {
const online = await isOnline();
if (!online) {
Alert.alert("You're offline", "Can't refresh right now. Showing cached data.", [
{ text: "OK" },
]);
return;
}
await refetchFn();
};
+142
View File
@@ -0,0 +1,142 @@
/**
* React Query Cache Persister
*
* Uses the shared storage adapter from utils/storage.ts for cross-platform support.
*/
import type { Persister, PersistedClient } from "@tanstack/react-query-persist-client";
import { CACHE_CONFIG } from "../config/cache.config";
import { generalStorage } from "./storage";
// Use the shared general storage adapter for cache persistence
const storage = generalStorage;
/**
* Create a React Query persister that works across all platforms
*
* This persister:
* - Saves the query cache to platform-appropriate storage
* - Restores cache on app launch for instant data display
* - Handles serialization/deserialization of cache data
* - Respects cache expiration (maxAge)
*/
export const createQueryPersister = (): Persister => {
const storageKey = CACHE_CONFIG.persistence.storageKey;
const maxAge = CACHE_CONFIG.persistence.maxAge;
return {
/**
* Persist the client state to storage
*/
persistClient: async (client: PersistedClient): Promise<void> => {
try {
const serialized = JSON.stringify(client);
await storage.setItem(storageKey, serialized);
} catch (error) {
console.warn("[QueryPersister] Failed to persist client:", error);
// Fail silently - persistence is a nice-to-have, not critical
}
},
/**
* Restore the client state from storage
*/
restoreClient: async (): Promise<PersistedClient | undefined> => {
try {
const serialized = await storage.getItem(storageKey);
if (!serialized) {
return undefined;
}
const client = JSON.parse(serialized) as PersistedClient;
// Validate timestamp exists and is a valid number
if (typeof client.timestamp !== "number" || isNaN(client.timestamp)) {
console.warn("[QueryPersister] Invalid or missing timestamp, discarding cache");
await storage.removeItem(storageKey);
return undefined;
}
// Check if the persisted cache has expired
const persistedAt = client.timestamp;
const now = Date.now();
if (now - persistedAt > maxAge) {
// Cache is too old, discard it
await storage.removeItem(storageKey);
return undefined;
}
return client;
} catch (error) {
console.warn("[QueryPersister] Failed to restore client:", error);
// If restoration fails, start fresh
return undefined;
}
},
/**
* Remove the persisted client state
*/
removeClient: async (): Promise<void> => {
try {
await storage.removeItem(storageKey);
} catch (error) {
console.warn("[QueryPersister] Failed to remove client:", error);
}
},
};
};
/**
* Export the storage adapter for potential direct use
*/
export { storage };
/**
* Utility to clear all query cache from storage
* Useful for logout or cache reset scenarios
*/
export const clearQueryCache = async (): Promise<void> => {
try {
await storage.removeItem(CACHE_CONFIG.persistence.storageKey);
} catch (error) {
console.warn("[QueryPersister] Failed to clear cache:", error);
}
};
/**
* Get cache metadata (for debugging/status display)
*/
export const getCacheMetadata = async (): Promise<{
exists: boolean;
timestamp?: number;
age?: number;
isExpired?: boolean;
} | null> => {
try {
const serialized = await storage.getItem(CACHE_CONFIG.persistence.storageKey);
if (!serialized) {
return { exists: false };
}
const client = JSON.parse(serialized) as PersistedClient;
// Validate timestamp exists and is a valid number
if (typeof client.timestamp !== "number" || isNaN(client.timestamp)) {
return { exists: true, isExpired: true }; // Treat invalid timestamp as expired
}
const now = Date.now();
const age = now - client.timestamp;
return {
exists: true,
timestamp: client.timestamp,
age,
isExpired: age > CACHE_CONFIG.persistence.maxAge,
};
} catch (error) {
console.warn("[QueryPersister] Failed to get cache metadata:", error);
return null;
}
};
+181
View File
@@ -0,0 +1,181 @@
/// <reference types="chrome" />
/**
* Unified Storage Adapter
*
* Cross-platform storage abstraction that works with:
* - SecureStore for React Native (iOS/Android) - for sensitive data
* - AsyncStorage for React Native (iOS/Android) - for general data
* - chrome.storage for browser extensions
* - localStorage as fallback for web
*
* This is the single source of truth for storage operations across the app.
*/
import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
/**
* Check if chrome.storage is available (browser extension context)
*/
export const isChromeStorageAvailable = (): boolean => {
return (
Platform.OS === "web" &&
typeof chrome !== "undefined" &&
chrome.storage !== undefined &&
chrome.storage.local !== undefined
);
};
/**
* Storage interface for type safety
*/
export interface StorageAdapter {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>;
removeItem: (key: string) => Promise<void>;
}
/**
* Secure storage for sensitive data (tokens, credentials)
* Uses SecureStore on mobile, chrome.storage on extension, localStorage on web
*/
export const secureStorage = {
get: async (key: string): Promise<string | null> => {
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.get([key], (result) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve((result[key] as string) ?? null);
}
});
});
}
if (Platform.OS === "web") {
return localStorage.getItem(key);
}
return await SecureStore.getItemAsync(key);
},
set: async (key: string, value: string): Promise<void> => {
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
if (Platform.OS === "web") {
localStorage.setItem(key, value);
return;
}
await SecureStore.setItemAsync(key, value);
},
remove: async (key: string): Promise<void> => {
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.remove(key, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
if (Platform.OS === "web") {
localStorage.removeItem(key);
return;
}
await SecureStore.deleteItemAsync(key);
},
removeAll: async (keys: string[]): Promise<void> => {
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.remove(keys, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
if (Platform.OS === "web") {
keys.forEach((key) => localStorage.removeItem(key));
return;
}
await Promise.all(keys.map((key) => SecureStore.deleteItemAsync(key)));
},
};
/**
* General storage for non-sensitive data (cache, preferences)
* Uses AsyncStorage on mobile, chrome.storage on extension, localStorage on web
*/
export const generalStorage: StorageAdapter = {
getItem: async (key: string): Promise<string | null> => {
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.get([key], (result) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve((result[key] as string) ?? null);
}
});
});
}
if (Platform.OS === "web") {
return Promise.resolve(localStorage.getItem(key));
}
return AsyncStorage.getItem(key);
},
setItem: async (key: string, value: string): Promise<void> => {
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
if (Platform.OS === "web") {
localStorage.setItem(key, value);
return Promise.resolve();
}
return AsyncStorage.setItem(key, value);
},
removeItem: async (key: string): Promise<void> => {
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.remove(key, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
if (Platform.OS === "web") {
localStorage.removeItem(key);
return Promise.resolve();
}
return AsyncStorage.removeItem(key);
},
};
+27
View File
@@ -50,6 +50,33 @@ export default defineConfig({
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI": JSON.stringify(
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI
),
// Cache configuration environment variables
"import.meta.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES": JSON.stringify(
process.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES
),
"import.meta.env.EXPO_PUBLIC_CACHE_GC_TIME_MINUTES": JSON.stringify(
process.env.EXPO_PUBLIC_CACHE_GC_TIME_MINUTES
),
"import.meta.env.EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES": JSON.stringify(
process.env.EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES
),
"import.meta.env.EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES": JSON.stringify(
process.env.EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES
),
"import.meta.env.EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES": JSON.stringify(
process.env.EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES
),
"import.meta.env.EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES": JSON.stringify(
process.env.EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES
),
// DEV ONLY: API Key for testing - only included in development builds
...(process.env.NODE_ENV !== "production" && process.env.EXPO_PUBLIC_CAL_API_KEY
? {
"import.meta.env.EXPO_PUBLIC_CAL_API_KEY": JSON.stringify(
process.env.EXPO_PUBLIC_CAL_API_KEY
),
}
: {}),
},
optimizeDeps: {
include: ["react-native-web"],