feat(companion): Add caching, redesign ui, and refactor code (#25654)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+770
-471
File diff suppressed because it is too large
Load Diff
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+533
-762
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+18
-108
@@ -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>
|
||||
);
|
||||
+133
-139
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Generated
+114
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Central export for all custom types
|
||||
*/
|
||||
|
||||
export * from "./locations";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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(" ");
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user