feat(companion): new availability detail and actions pages for ios (#26424)
* version 1 * version 1.1 * better code * covered all edge cases * address cubics comments * address cubics comments
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function AvailabilityLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="availability-detail" options={{ headerShown: Platform.OS === "ios" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useRef } from "react";
|
||||
import {
|
||||
AvailabilityDetailScreen,
|
||||
type AvailabilityDetailScreenHandle,
|
||||
} from "@/components/screens/AvailabilityDetailScreen";
|
||||
|
||||
// Type for action handlers exposed by AvailabilityDetailScreen.ios.tsx
|
||||
type ActionHandlers = {
|
||||
handleSetAsDefault: () => void;
|
||||
handleDelete: () => void;
|
||||
};
|
||||
|
||||
export default function AvailabilityDetailIOS() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
|
||||
// Ref to store action handlers from AvailabilityDetailScreen
|
||||
const actionHandlersRef = useRef<ActionHandlers | null>(null);
|
||||
const screenRef = useRef<AvailabilityDetailScreenHandle>(null);
|
||||
|
||||
// Callback to receive action handlers from AvailabilityDetailScreen
|
||||
const handleActionsReady = useCallback((handlers: ActionHandlers) => {
|
||||
actionHandlersRef.current = handlers;
|
||||
}, []);
|
||||
|
||||
// Navigation handlers for edit bottom sheets
|
||||
const handleEditNameAndTimezone = useCallback(() => {
|
||||
router.push(`/edit-availability-name?id=${id}` as never);
|
||||
}, [router, id]);
|
||||
|
||||
const handleEditWorkingHours = useCallback(() => {
|
||||
router.push(`/edit-availability-hours?id=${id}` as never);
|
||||
}, [router, id]);
|
||||
|
||||
const handleEditOverride = useCallback(() => {
|
||||
router.push(`/edit-availability-override?id=${id}` as never);
|
||||
}, [router, id]);
|
||||
|
||||
// Action handlers for inline actions
|
||||
const handleSetAsDefault = useCallback(() => {
|
||||
if (actionHandlersRef.current?.handleSetAsDefault) {
|
||||
actionHandlersRef.current.handleSetAsDefault();
|
||||
} else if (screenRef.current?.setAsDefault) {
|
||||
screenRef.current.setAsDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (actionHandlersRef.current?.handleDelete) {
|
||||
actionHandlersRef.current.handleDelete();
|
||||
} else if (screenRef.current?.delete) {
|
||||
screenRef.current.delete();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Availability",
|
||||
headerBackTitle: "Availability",
|
||||
headerBackButtonDisplayMode: "default",
|
||||
headerTitle: "",
|
||||
headerStyle: {
|
||||
backgroundColor: "#f2f2f7",
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header style={{ shadowColor: "transparent", backgroundColor: "#f2f2f7" }}>
|
||||
<Stack.Header.Right>
|
||||
{/* Edit Menu */}
|
||||
<Stack.Header.Menu>
|
||||
<Stack.Header.Label>Edit</Stack.Header.Label>
|
||||
|
||||
{/* Name and Timezone */}
|
||||
<Stack.Header.MenuAction icon="pencil" onPress={handleEditNameAndTimezone}>
|
||||
Name and Timezone
|
||||
</Stack.Header.MenuAction>
|
||||
|
||||
{/* Working Hours */}
|
||||
<Stack.Header.MenuAction icon="clock" onPress={handleEditWorkingHours}>
|
||||
Working Hours
|
||||
</Stack.Header.MenuAction>
|
||||
|
||||
{/* Date Override */}
|
||||
<Stack.Header.MenuAction icon="calendar.badge.plus" onPress={handleEditOverride}>
|
||||
Date Override
|
||||
</Stack.Header.MenuAction>
|
||||
|
||||
{/* Set as Default */}
|
||||
<Stack.Header.MenuAction icon="star" onPress={handleSetAsDefault}>
|
||||
Set as Default
|
||||
</Stack.Header.MenuAction>
|
||||
|
||||
{/* Delete */}
|
||||
<Stack.Header.MenuAction icon="trash" onPress={handleDelete} destructive>
|
||||
Delete Schedule
|
||||
</Stack.Header.MenuAction>
|
||||
</Stack.Header.Menu>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
<AvailabilityDetailScreen
|
||||
ref={screenRef}
|
||||
id={id}
|
||||
// @ts-expect-error - onActionsReady is only available in AvailabilityDetailScreen.ios.tsx
|
||||
onActionsReady={handleActionsReady}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export default function Availability() {
|
||||
onSuccess: (newSchedule) => {
|
||||
// Navigate to edit the newly created schedule
|
||||
router.push({
|
||||
pathname: "/availability-detail",
|
||||
pathname: "/(tabs)/(availability)/availability-detail",
|
||||
params: {
|
||||
id: newSchedule.id.toString(),
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { AdvancedTab } from "@/components/event-type-detail/tabs/AdvancedTab";
|
||||
import { AvailabilityTab } from "@/components/event-type-detail/tabs/AvailabilityTab";
|
||||
@@ -127,7 +126,6 @@ export default function EventTypeDetail() {
|
||||
slug?: string;
|
||||
}>();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeTab, setActiveTab] = useState("basics");
|
||||
|
||||
// Form state
|
||||
|
||||
@@ -198,6 +198,110 @@ function RootLayoutContent() {
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="edit-availability-name"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Edit Name & Timezone",
|
||||
presentation:
|
||||
Platform.OS === "ios"
|
||||
? isLiquidGlassAvailable()
|
||||
? "formSheet"
|
||||
: "modal"
|
||||
: "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.5, 0.7] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="edit-availability-hours"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Working Hours",
|
||||
presentation:
|
||||
Platform.OS === "ios"
|
||||
? isLiquidGlassAvailable()
|
||||
? "formSheet"
|
||||
: "modal"
|
||||
: "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 1] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="edit-availability-day"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Edit Day",
|
||||
presentation:
|
||||
Platform.OS === "ios"
|
||||
? isLiquidGlassAvailable()
|
||||
? "formSheet"
|
||||
: "modal"
|
||||
: "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.6, 0.9] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="edit-availability-override"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerLargeTitle: false,
|
||||
title: "Date Override",
|
||||
presentation:
|
||||
Platform.OS === "ios"
|
||||
? isLiquidGlassAvailable()
|
||||
? "formSheet"
|
||||
: "modal"
|
||||
: "containedModal",
|
||||
sheetGrabberVisible: Platform.OS === "ios",
|
||||
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 0.9] : undefined,
|
||||
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
|
||||
},
|
||||
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<LoginScreenComponent />
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen.ios";
|
||||
import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen.ios";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// Semi-transparent background to prevent black flash while preserving glass effect
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function EditAvailabilityDayIOS() {
|
||||
const { id, day } = useLocalSearchParams<{ id: string; day: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const screenRef = useRef<EditAvailabilityDayScreenHandle>(null);
|
||||
|
||||
const dayIndex = day ? parseInt(day, 10) : 0;
|
||||
const dayName = DAYS[dayIndex] || "Day";
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
screenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: dayName,
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.6, 0.9],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>{dayName}</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
variant="prominent"
|
||||
tintColor="#000"
|
||||
>
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<EditAvailabilityDayScreenComponent
|
||||
ref={screenRef}
|
||||
schedule={schedule}
|
||||
dayIndex={dayIndex}
|
||||
onSuccess={handleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen";
|
||||
import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export default function EditAvailabilityDay() {
|
||||
const { id, day } = useLocalSearchParams<{ id: string; day: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const screenRef = useRef<EditAvailabilityDayScreenHandle>(null);
|
||||
|
||||
const dayIndex = day ? parseInt(day, 10) : 0;
|
||||
const dayName = DAYS[dayIndex] || "Day";
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
screenRef.current?.submit();
|
||||
}, [isSaving]);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center bg-white"
|
||||
style={{ paddingBottom: insets.bottom }}
|
||||
>
|
||||
<Stack.Screen options={{ title: dayName }} />
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: dayName,
|
||||
headerRight: () => (
|
||||
<Text
|
||||
onPress={handleSave}
|
||||
className={`text-[17px] font-semibold ${
|
||||
isSaving ? "text-gray-400" : "text-[#007AFF]"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<EditAvailabilityDayScreenComponent
|
||||
ref={screenRef}
|
||||
schedule={schedule}
|
||||
dayIndex={dayIndex}
|
||||
onSuccess={handleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen.ios";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
|
||||
// Semi-transparent background to prevent black flash while preserving glass effect
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function EditAvailabilityHoursIOS() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleDayPress = useCallback(
|
||||
(dayIndex: number) => {
|
||||
router.push(`/edit-availability-day?id=${id}&day=${dayIndex}` as never);
|
||||
},
|
||||
[router, id]
|
||||
);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Working Hours",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Working Hours</Stack.Header.Title>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<EditAvailabilityHoursScreenComponent
|
||||
schedule={schedule}
|
||||
onDayPress={handleDayPress}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
|
||||
export default function EditAvailabilityHours() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleDayPress = useCallback(
|
||||
(dayIndex: number) => {
|
||||
router.push(`/edit-availability-day?id=${id}&day=${dayIndex}` as never);
|
||||
},
|
||||
[router, id]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center bg-white"
|
||||
style={{ paddingBottom: insets.bottom }}
|
||||
>
|
||||
<Stack.Screen options={{ title: "Working Hours" }} />
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom }}>
|
||||
<Stack.Screen options={{ title: "Working Hours" }} />
|
||||
<EditAvailabilityHoursScreenComponent schedule={schedule} onDayPress={handleDayPress} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen.ios";
|
||||
import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen.ios";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
|
||||
// Semi-transparent background to prevent black flash while preserving glass effect
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function EditAvailabilityNameIOS() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const screenRef = useRef<EditAvailabilityNameScreenHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
screenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Edit Name & Timezone",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>Edit Name & Timezone</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
variant="prominent"
|
||||
tintColor="#000"
|
||||
>
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<EditAvailabilityNameScreenComponent
|
||||
ref={screenRef}
|
||||
schedule={schedule}
|
||||
onSuccess={handleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen";
|
||||
import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
|
||||
export default function EditAvailabilityName() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const screenRef = useRef<EditAvailabilityNameScreenHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
screenRef.current?.submit();
|
||||
}, [isSaving]);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center bg-white"
|
||||
style={{ paddingBottom: insets.bottom }}
|
||||
>
|
||||
<Stack.Screen options={{ title: "Edit Name & Timezone" }} />
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "Edit Name & Timezone",
|
||||
headerRight: () => (
|
||||
<Text
|
||||
onPress={handleSave}
|
||||
className={`text-[17px] font-semibold ${
|
||||
isSaving ? "text-gray-400" : "text-[#007AFF]"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<EditAvailabilityNameScreenComponent
|
||||
ref={screenRef}
|
||||
schedule={schedule}
|
||||
onSuccess={handleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen.ios";
|
||||
import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen.ios";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
|
||||
// Semi-transparent background to prevent black flash while preserving glass effect
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
|
||||
function getPresentationStyle(): "formSheet" | "modal" {
|
||||
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
|
||||
return "formSheet";
|
||||
}
|
||||
return "modal";
|
||||
}
|
||||
|
||||
export default function EditAvailabilityOverrideIOS() {
|
||||
const { id, overrideIndex } = useLocalSearchParams<{
|
||||
id: string;
|
||||
overrideIndex?: string;
|
||||
}>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const screenRef = useRef<EditAvailabilityOverrideScreenHandle>(null);
|
||||
|
||||
const editingIndex = overrideIndex ? parseInt(overrideIndex, 10) : undefined;
|
||||
const isEditing = editingIndex !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
screenRef.current?.submit();
|
||||
}, []);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleEditOverride = useCallback(
|
||||
(index: number) => {
|
||||
// Push a new edit screen on top of the current one
|
||||
// This allows user to go back to the override list after editing
|
||||
router.push(`/edit-availability-override?id=${id}&overrideIndex=${index}` as never);
|
||||
},
|
||||
[id, router]
|
||||
);
|
||||
|
||||
const presentationStyle = getPresentationStyle();
|
||||
const useGlassEffect = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: isEditing ? "Edit Override" : "Add Override",
|
||||
presentation: presentationStyle,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.7, 1],
|
||||
sheetInitialDetentIndex: 0,
|
||||
contentStyle: {
|
||||
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Header>
|
||||
<Stack.Header.Left>
|
||||
<Stack.Header.Button onPress={() => router.back()}>
|
||||
<Stack.Header.Icon sf="xmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Left>
|
||||
|
||||
<Stack.Header.Title>{isEditing ? "Edit Override" : "Add Override"}</Stack.Header.Title>
|
||||
|
||||
<Stack.Header.Right>
|
||||
<Stack.Header.Button
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
variant="prominent"
|
||||
tintColor="#000"
|
||||
>
|
||||
<Stack.Header.Icon sf="checkmark" />
|
||||
</Stack.Header.Button>
|
||||
</Stack.Header.Right>
|
||||
</Stack.Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : (
|
||||
<EditAvailabilityOverrideScreenComponent
|
||||
ref={screenRef}
|
||||
schedule={schedule}
|
||||
overrideIndex={editingIndex}
|
||||
onSuccess={handleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
onEditOverride={handleEditOverride}
|
||||
transparentBackground={useGlassEffect}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen";
|
||||
import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
|
||||
export default function EditAvailabilityOverride() {
|
||||
const { id, overrideIndex } = useLocalSearchParams<{
|
||||
id: string;
|
||||
overrideIndex?: string;
|
||||
}>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const screenRef = useRef<EditAvailabilityOverrideScreenHandle>(null);
|
||||
|
||||
const editingIndex = overrideIndex ? parseInt(overrideIndex, 10) : undefined;
|
||||
const isEditing = editingIndex !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setIsLoading(true);
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
screenRef.current?.submit();
|
||||
}, [isSaving]);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleEditOverride = useCallback(
|
||||
(index: number) => {
|
||||
// Push a new edit screen on top of the current one
|
||||
// This allows user to go back to the override list after editing
|
||||
router.push(`/edit-availability-override?id=${id}&overrideIndex=${index}` as never);
|
||||
},
|
||||
[id, router]
|
||||
);
|
||||
|
||||
const title = isEditing ? "Edit Override" : "Add Override";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center bg-white"
|
||||
style={{ paddingBottom: insets.bottom }}
|
||||
>
|
||||
<Stack.Screen options={{ title }} />
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title,
|
||||
headerRight: () => (
|
||||
<Text
|
||||
onPress={handleSave}
|
||||
className={`text-[17px] font-semibold ${
|
||||
isSaving ? "text-gray-400" : "text-[#007AFF]"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<EditAvailabilityOverrideScreenComponent
|
||||
ref={screenRef}
|
||||
schedule={schedule}
|
||||
overrideIndex={editingIndex}
|
||||
onSuccess={handleSuccess}
|
||||
onSavingChange={setIsSaving}
|
||||
onEditOverride={handleEditOverride}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
+8
-8
@@ -9,14 +9,14 @@
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-segmented-control/segmented-control": "2.5.7",
|
||||
"@rn-primitives/alert-dialog": "^1.2.0",
|
||||
"@rn-primitives/dropdown-menu": "^1.2.0",
|
||||
"@rn-primitives/slot": "^1.2.0",
|
||||
"@rn-primitives/alert-dialog": "1.2.0",
|
||||
"@rn-primitives/dropdown-menu": "1.2.0",
|
||||
"@rn-primitives/slot": "1.2.0",
|
||||
"@tanstack/react-query": "5.62.0",
|
||||
"@tanstack/react-query-persist-client": "5.62.0",
|
||||
"base64-js": "1.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"expo": "55.0.0-canary-20251230-fc48ddc",
|
||||
"expo-auth-session": "7.0.11-canary-20251230-fc48ddc",
|
||||
"expo-clipboard": "9.0.0-canary-20251230-fc48ddc",
|
||||
@@ -33,7 +33,7 @@
|
||||
"expo-splash-screen": "31.0.14-canary-20251230-fc48ddc",
|
||||
"expo-status-bar": "3.0.10-canary-20251230-fc48ddc",
|
||||
"expo-web-browser": "15.0.11-canary-20251230-fc48ddc",
|
||||
"lucide-react-native": "^0.562.0",
|
||||
"lucide-react-native": "0.562.0",
|
||||
"nativewind": "4.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
@@ -45,8 +45,8 @@
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "0.21.2",
|
||||
"react-native-worklets": "0.7.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.10",
|
||||
|
||||
@@ -89,8 +89,9 @@ export const AvailabilityListItem = ({
|
||||
onPress={() => handleSchedulePress(schedule)}
|
||||
className="mr-4 flex-1"
|
||||
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
|
||||
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
|
||||
<TimeZoneRow timeZone={schedule.timeZone} />
|
||||
@@ -102,7 +103,7 @@ export const AvailabilityListItem = ({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Pressable
|
||||
className="items-center justify-center rounded-lg border border-cal-border"
|
||||
style={{ width: 32, height: 32 }}
|
||||
style={{ width: 32, height: 32, flexShrink: 0 }}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
|
||||
</Pressable>
|
||||
|
||||
@@ -71,8 +71,9 @@ export const AvailabilityListItem = ({
|
||||
onPress={() => handleSchedulePress(schedule)}
|
||||
className="mr-4 flex-1"
|
||||
accessibilityRole="button"
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
|
||||
<AvailabilitySlots
|
||||
availability={schedule.availability}
|
||||
@@ -85,7 +86,7 @@ export const AvailabilityListItem = ({
|
||||
{/* Three dots menu - fixed hit target so it doesn't get squeezed off-screen */}
|
||||
<View
|
||||
className="items-center justify-center rounded-lg border border-cal-border"
|
||||
style={{ width: 32, height: 32 }}
|
||||
style={{ width: 32, height: 32, flexShrink: 0 }}
|
||||
>
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
|
||||
@@ -34,16 +34,18 @@ export const AvailabilityListItem = ({
|
||||
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<View className="mr-4 flex-1" style={{ minWidth: 0 }}>
|
||||
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
|
||||
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
|
||||
<TimeZoneRow timeZone={schedule.timeZone} />
|
||||
</View>
|
||||
<ScheduleActionsButton
|
||||
schedule={schedule}
|
||||
setSelectedSchedule={setSelectedSchedule}
|
||||
setShowActionsModal={setShowActionsModal}
|
||||
/>
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<ScheduleActionsButton
|
||||
schedule={schedule}
|
||||
setSelectedSchedule={setSelectedSchedule}
|
||||
setShowActionsModal={setShowActionsModal}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,16 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import type { Schedule } from "@/hooks";
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return `${hour12}:${min} ${period}`;
|
||||
};
|
||||
|
||||
interface ScheduleNameProps {
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
@@ -30,18 +40,28 @@ export function AvailabilitySlots({ availability, scheduleId }: AvailabilitySlot
|
||||
return <Text className="text-sm text-cal-text-secondary">No availability set</Text>;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SLOTS = 2;
|
||||
const visibleSlots = availability.slice(0, MAX_VISIBLE_SLOTS);
|
||||
const remainingCount = availability.length - MAX_VISIBLE_SLOTS;
|
||||
|
||||
return (
|
||||
<View>
|
||||
{availability.map((slot, slotIndex) => (
|
||||
{visibleSlots.map((slot, slotIndex) => (
|
||||
<View
|
||||
key={`${scheduleId}-${slot.days.join("-")}-${slotIndex}`}
|
||||
className={slotIndex > 0 ? "mt-2" : ""}
|
||||
className={slotIndex > 0 ? "mt-1" : ""}
|
||||
>
|
||||
<Text className="text-sm text-cal-text-secondary">
|
||||
{slot.days.join(", ")} {slot.startTime} - {slot.endTime}
|
||||
<Text className="text-sm text-cal-text-secondary" numberOfLines={1}>
|
||||
{slot.days.join(", ")} {formatTime12Hour(slot.startTime)} -{" "}
|
||||
{formatTime12Hour(slot.endTime)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Text className="mt-1 text-sm text-cal-text-secondary">
|
||||
+{remainingCount} more {remainingCount === 1 ? "slot" : "slots"}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
if (guests.length === 0) {
|
||||
Alert.alert("Error", "Please add at least one guest");
|
||||
@@ -110,7 +110,7 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
Alert.alert("Error", "Failed to add guests. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, guests, onSuccess]);
|
||||
}, [booking, guests, onSuccess, isSaving]);
|
||||
|
||||
// Expose submit function to parent via ref (same pattern as senior's actionHandlersRef)
|
||||
useImperativeHandle(
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* AvailabilityDetailScreen Component (iOS)
|
||||
*
|
||||
* iOS-specific read-only display of availability schedule.
|
||||
* Editing is done via separate bottom sheet screens.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { ActivityIndicator, Alert, ScrollView, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import type { ScheduleAvailability } from "@/services/types";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// Map day names to numbers
|
||||
const DAY_NAME_TO_NUMBER: Record<string, number> = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
const hour12Padded = hour12.toString().padStart(2, "0");
|
||||
return `${hour12Padded}:${min} ${period}`;
|
||||
};
|
||||
|
||||
// Format availability for display - groups days with same time range
|
||||
const formatAvailabilityDisplay = (
|
||||
availability: Record<number, ScheduleAvailability[]>
|
||||
): string[] => {
|
||||
const timeRangeMap: Record<string, number[]> = {};
|
||||
|
||||
Object.keys(availability).forEach((dayIndexStr) => {
|
||||
const dayIndex = Number(dayIndexStr);
|
||||
const slots = availability[dayIndex];
|
||||
if (slots && slots.length > 0) {
|
||||
slots.forEach((slot) => {
|
||||
const timeKey = `${slot.startTime}-${slot.endTime}`;
|
||||
if (!timeRangeMap[timeKey]) {
|
||||
timeRangeMap[timeKey] = [];
|
||||
}
|
||||
timeRangeMap[timeKey].push(dayIndex);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const formatted: string[] = [];
|
||||
Object.keys(timeRangeMap).forEach((timeKey) => {
|
||||
const days = timeRangeMap[timeKey].sort((a, b) => a - b);
|
||||
const [startTime, endTime] = timeKey.split("-");
|
||||
const dayNames = days.map((day) => DAYS[day]).join(", ");
|
||||
const timeRange = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
|
||||
formatted.push(`${dayNames}, ${timeRange}`);
|
||||
});
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDateForDisplay = (dateStr: string): string => {
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(`${dateStr}T00:00:00`);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export interface AvailabilityDetailScreenProps {
|
||||
id: string;
|
||||
onActionsReady?: (handlers: { handleSetAsDefault: () => void; handleDelete: () => void }) => void;
|
||||
}
|
||||
|
||||
export interface AvailabilityDetailScreenHandle {
|
||||
setAsDefault: () => void;
|
||||
delete: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export const AvailabilityDetailScreen = forwardRef<
|
||||
AvailabilityDetailScreenHandle,
|
||||
AvailabilityDetailScreenProps
|
||||
>(function AvailabilityDetailScreen({ id, onActionsReady }, ref) {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [_schedule, setSchedule] = useState<Schedule | null>(null);
|
||||
const [scheduleName, setScheduleName] = useState("");
|
||||
const [timeZone, setTimeZone] = useState("");
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [availability, setAvailability] = useState<Record<number, ScheduleAvailability[]>>({});
|
||||
const [overrides, setOverrides] = useState<
|
||||
{
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}[]
|
||||
>([]);
|
||||
const [daysExpanded, setDaysExpanded] = useState(true);
|
||||
const [overridesExpanded, setOverridesExpanded] = useState(true);
|
||||
|
||||
const processScheduleData = useCallback(
|
||||
(scheduleData: NonNullable<Awaited<ReturnType<typeof CalComAPIService.getScheduleById>>>) => {
|
||||
const name = scheduleData.name ?? "";
|
||||
const tz = scheduleData.timeZone ?? "UTC";
|
||||
const isDefaultSchedule = scheduleData.isDefault ?? false;
|
||||
|
||||
setSchedule(scheduleData);
|
||||
setScheduleName(name);
|
||||
setTimeZone(tz);
|
||||
setIsDefault(isDefaultSchedule);
|
||||
|
||||
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
|
||||
|
||||
const availabilityArray = scheduleData.availability;
|
||||
if (availabilityArray && Array.isArray(availabilityArray)) {
|
||||
availabilityArray.forEach((slot) => {
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
|
||||
return DAY_NAME_TO_NUMBER[day];
|
||||
}
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
days.forEach((day) => {
|
||||
if (!availabilityMap[day]) {
|
||||
availabilityMap[day] = [];
|
||||
}
|
||||
const startTime = slot.startTime ?? "09:00:00";
|
||||
const endTime = slot.endTime ?? "17:00:00";
|
||||
availabilityMap[day].push({
|
||||
days: [day.toString()],
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setAvailability(availabilityMap);
|
||||
|
||||
const overridesArray = scheduleData.overrides;
|
||||
if (overridesArray && Array.isArray(overridesArray)) {
|
||||
const formattedOverrides = overridesArray.map((override) => {
|
||||
const date = override.date ?? "";
|
||||
const startTime = override.startTime ?? "00:00";
|
||||
const endTime = override.endTime ?? "00:00";
|
||||
return { date, startTime, endTime };
|
||||
});
|
||||
setOverrides(formattedOverrides);
|
||||
} else {
|
||||
setOverrides([]);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchSchedule = useCallback(async () => {
|
||||
setLoading(true);
|
||||
let scheduleData: Awaited<ReturnType<typeof CalComAPIService.getScheduleById>> = null;
|
||||
try {
|
||||
scheduleData = await CalComAPIService.getScheduleById(Number(id));
|
||||
} catch (error) {
|
||||
console.error("Error fetching schedule");
|
||||
if (__DEV__) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.debug("[AvailabilityDetailScreen.ios] fetchSchedule failed", {
|
||||
message,
|
||||
});
|
||||
}
|
||||
showErrorAlert("Error", "Failed to load availability. Please try again.");
|
||||
router.back();
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleData) {
|
||||
processScheduleData(scheduleData);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [id, router, processScheduleData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchSchedule();
|
||||
}
|
||||
}, [id, fetchSchedule]);
|
||||
|
||||
const handleSetAsDefault = useCallback(async () => {
|
||||
if (isDefault) {
|
||||
Alert.alert("Info", "This schedule is already set as default");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(Number(id), {
|
||||
isDefault: true,
|
||||
});
|
||||
setIsDefault(true);
|
||||
Alert.alert("Success", "Availability set as default successfully");
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to set availability as default. Please try again.");
|
||||
}
|
||||
}, [isDefault, id]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (isDefault) {
|
||||
Alert.alert(
|
||||
"Cannot Delete",
|
||||
"You cannot delete the default schedule. Please set another schedule as default first."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert("Delete Availability", `Are you sure you want to delete "${scheduleName}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await CalComAPIService.deleteSchedule(Number(id));
|
||||
Alert.alert("Success", "Availability deleted successfully", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to delete availability. Please try again.");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}, [isDefault, scheduleName, id, router]);
|
||||
|
||||
// Expose handlers via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
setAsDefault: handleSetAsDefault,
|
||||
delete: handleDelete,
|
||||
refresh: fetchSchedule,
|
||||
}),
|
||||
[handleSetAsDefault, handleDelete, fetchSchedule]
|
||||
);
|
||||
|
||||
// Expose handlers to parent for iOS header menu
|
||||
useEffect(() => {
|
||||
if (onActionsReady) {
|
||||
onActionsReady({
|
||||
handleSetAsDefault,
|
||||
handleDelete,
|
||||
});
|
||||
}
|
||||
}, [onActionsReady, handleSetAsDefault, handleDelete]);
|
||||
|
||||
// Count enabled days
|
||||
const enabledDaysCount = Object.keys(availability).length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#f2f2f7]">
|
||||
<ActivityIndicator size="large" color="#000000" />
|
||||
<Text className="mt-4 text-[15px] text-[#8E8E93]">Loading availability...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-[#f2f2f7]">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Schedule Title Section - iOS Calendar Style */}
|
||||
<View className="mb-4">
|
||||
<View className="mb-2 flex-row flex-wrap items-center">
|
||||
<Text
|
||||
className="text-[26px] font-semibold leading-tight text-black"
|
||||
style={{ letterSpacing: -0.3 }}
|
||||
>
|
||||
{scheduleName || "Untitled Schedule"}
|
||||
</Text>
|
||||
{isDefault && (
|
||||
<Ionicons
|
||||
name="star-outline"
|
||||
size={20}
|
||||
color="#000000"
|
||||
style={{ marginLeft: 6, marginTop: 2 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Working Hours Summary Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<View className="px-4 py-3.5">
|
||||
<Text className="mb-2.5 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
|
||||
Working Hours
|
||||
</Text>
|
||||
{Object.keys(availability).length > 0 ? (
|
||||
<View>
|
||||
{formatAvailabilityDisplay(availability).map((line) => (
|
||||
<Text key={line} className="mb-1 text-[17px] text-black">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-[17px] text-[#8E8E93]">No availability set</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Weekly Schedule Card - Expandable */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<AppPressable
|
||||
className="flex-row items-center justify-between px-4 py-3.5"
|
||||
onPress={() => setDaysExpanded(!daysExpanded)}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Weekly Schedule</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]">
|
||||
{enabledDaysCount} {enabledDaysCount === 1 ? "day" : "days"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={daysExpanded ? "chevron-down" : "chevron-forward"}
|
||||
size={18}
|
||||
color="#C7C7CC"
|
||||
/>
|
||||
</View>
|
||||
</AppPressable>
|
||||
|
||||
{daysExpanded && (
|
||||
<View className="border-t border-[#E5E5EA] px-4 py-2.5">
|
||||
{DAYS.map((day, dayIndex) => {
|
||||
const daySlots = availability[dayIndex] || [];
|
||||
const isEnabled = daySlots.length > 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={day}
|
||||
className={`flex-row items-center py-3 ${
|
||||
dayIndex > 0 ? "border-t border-[#E5E5EA]" : ""
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className={`mr-3 h-2.5 w-2.5 rounded-full ${
|
||||
isEnabled ? "bg-[#34C759]" : "bg-[#E5E5EA]"
|
||||
}`}
|
||||
/>
|
||||
<Text
|
||||
className={`w-24 text-[15px] font-medium ${
|
||||
isEnabled ? "text-black" : "text-[#8E8E93]"
|
||||
}`}
|
||||
>
|
||||
{DAYS[dayIndex]}
|
||||
</Text>
|
||||
|
||||
{isEnabled ? (
|
||||
<View className="flex-1 items-end">
|
||||
{daySlots.map((slot, slotIndex) => (
|
||||
<Text
|
||||
key={`${slotIndex}-${slot.startTime}`}
|
||||
className={`text-[15px] text-black ${slotIndex > 0 ? "mt-1" : ""}`}
|
||||
>
|
||||
{formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="flex-1 text-right text-[15px] text-[#8E8E93]">
|
||||
Unavailable
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Timezone Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<View className="flex-row items-center justify-between px-4 py-3.5">
|
||||
<Text className="text-[17px] text-black">Timezone</Text>
|
||||
<Text className="text-[17px] text-[#8E8E93]">{timeZone}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Date Overrides Card - Expandable */}
|
||||
{overrides.length > 0 && (
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<AppPressable
|
||||
className="flex-row items-center justify-between px-4 py-3.5"
|
||||
onPress={() => setOverridesExpanded(!overridesExpanded)}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Date Overrides</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]">
|
||||
{overrides.length} {overrides.length === 1 ? "override" : "overrides"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={overridesExpanded ? "chevron-down" : "chevron-forward"}
|
||||
size={18}
|
||||
color="#C7C7CC"
|
||||
/>
|
||||
</View>
|
||||
</AppPressable>
|
||||
|
||||
{overridesExpanded && (
|
||||
<View className="border-t border-[#E5E5EA] px-4 py-2.5">
|
||||
{overrides.map((override, index) => (
|
||||
<View
|
||||
key={override.date}
|
||||
className={`py-2.5 ${index > 0 ? "border-t border-[#E5E5EA]" : ""}`}
|
||||
>
|
||||
<Text className="mb-0.5 text-[17px] text-black">
|
||||
{formatDateForDisplay(override.date)}
|
||||
</Text>
|
||||
{override.startTime === "00:00" && override.endTime === "00:00" ? (
|
||||
<Text className="text-[15px] text-[#FF3B30]">Unavailable</Text>
|
||||
) : (
|
||||
<Text className="text-[15px] text-[#8E8E93]">
|
||||
{formatTime12Hour(`${override.startTime}:00`)} –{" "}
|
||||
{formatTime12Hour(`${override.endTime}:00`)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No Overrides Message */}
|
||||
{overrides.length === 0 && (
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<View className="px-4 py-3.5">
|
||||
<Text className="text-[17px] text-black">Date Overrides</Text>
|
||||
<Text className="mt-1 text-[15px] text-[#8E8E93]">No date overrides set</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { ActivityIndicator, Alert, ScrollView, Switch, Text, TextInput, View } f
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import { TIMEZONES } from "@/constants/timezones";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import type { ScheduleAvailability } from "@/services/types";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
@@ -92,6 +93,9 @@ export interface AvailabilityDetailScreenProps {
|
||||
|
||||
export interface AvailabilityDetailScreenHandle {
|
||||
save: () => void;
|
||||
setAsDefault: () => void;
|
||||
delete: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export const AvailabilityDetailScreen = forwardRef<
|
||||
@@ -131,21 +135,8 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
type: "start" | "end";
|
||||
} | null>(null);
|
||||
|
||||
// Common timezones
|
||||
const timezones = [
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Kolkata",
|
||||
"Australia/Sydney",
|
||||
"UTC",
|
||||
];
|
||||
// Use all supported timezones from the shared constants
|
||||
const timezones = TIMEZONES;
|
||||
|
||||
const processScheduleData = useCallback(
|
||||
(scheduleData: NonNullable<Awaited<ReturnType<typeof CalComAPIService.getScheduleById>>>) => {
|
||||
@@ -237,7 +228,7 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
message,
|
||||
});
|
||||
}
|
||||
showErrorAlert("Error", "Failed to load schedule. Please try again.");
|
||||
showErrorAlert("Error", "Failed to load availability. Please try again.");
|
||||
router.back();
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -325,6 +316,12 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate availability name
|
||||
if (!scheduleName.trim()) {
|
||||
Alert.alert("Error", "Please enter an availability name");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// Convert availability object back to array format with day names
|
||||
@@ -364,42 +361,37 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
}));
|
||||
|
||||
await CalComAPIService.updateSchedule(Number(id), {
|
||||
name: scheduleName,
|
||||
name: scheduleName.trim(),
|
||||
timeZone,
|
||||
availability: availabilityArray,
|
||||
isDefault: isDefault,
|
||||
overrides: formattedOverrides,
|
||||
});
|
||||
|
||||
Alert.alert("Success", "Schedule updated successfully", [
|
||||
Alert.alert("Success", "Availability updated successfully", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
setSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to update schedule. Please try again.");
|
||||
showErrorAlert("Error", "Failed to update availability. Please try again.");
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose save method to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: handleSave,
|
||||
}));
|
||||
|
||||
const handleSetAsDefault = async () => {
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(Number(id), {
|
||||
isDefault: true,
|
||||
});
|
||||
setIsDefault(true);
|
||||
Alert.alert("Success", "Schedule set as default successfully");
|
||||
Alert.alert("Success", "Availability set as default successfully");
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
|
||||
showErrorAlert("Error", "Failed to set availability as default. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert("Delete Schedule", `Are you sure you want to delete "${scheduleName}"?`, [
|
||||
Alert.alert("Delete Availability", `Are you sure you want to delete "${scheduleName}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
@@ -407,17 +399,25 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
onPress: async () => {
|
||||
try {
|
||||
await CalComAPIService.deleteSchedule(Number(id));
|
||||
Alert.alert("Success", "Schedule deleted successfully", [
|
||||
Alert.alert("Success", "Availability deleted successfully", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
|
||||
showErrorAlert("Error", "Failed to delete availability. Please try again.");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: handleSave,
|
||||
setAsDefault: handleSetAsDefault,
|
||||
delete: handleDelete,
|
||||
refresh: fetchSchedule,
|
||||
}));
|
||||
|
||||
const handleAddOverride = () => {
|
||||
setEditingOverride(null);
|
||||
setOverrideDate("");
|
||||
@@ -489,7 +489,7 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
|
||||
<ActivityIndicator size="large" color="#000000" />
|
||||
<Text className="mt-4 text-base text-[#666]">Loading schedule...</Text>
|
||||
<Text className="mt-4 text-base text-[#666]">Loading availability...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -547,9 +547,21 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 200 }}
|
||||
>
|
||||
<View className="gap-4">
|
||||
{/* Schedule Name and Working Hours Display */}
|
||||
{/* Availability Name */}
|
||||
<View className="rounded-2xl bg-white p-6">
|
||||
<Text className="mb-3 text-xl font-bold text-[#333]">{scheduleName}</Text>
|
||||
<Text className="mb-3 text-base font-semibold text-[#333]">Availability Name</Text>
|
||||
<TextInput
|
||||
value={scheduleName}
|
||||
onChangeText={setScheduleName}
|
||||
placeholder="Enter availability name"
|
||||
placeholderTextColor="#999"
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-[#333]"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Working Hours Display */}
|
||||
<View className="rounded-2xl bg-white p-6">
|
||||
<Text className="mb-3 text-xl font-bold text-[#333]">Working Hours</Text>
|
||||
{Object.keys(availability).length > 0 ? (
|
||||
<View>
|
||||
{formatAvailabilityDisplay(availability).map((line) => (
|
||||
|
||||
@@ -230,7 +230,7 @@ export function AvailabilityListScreen({
|
||||
|
||||
const handleSchedulePress = (schedule: Schedule) => {
|
||||
router.push({
|
||||
pathname: "/availability-detail",
|
||||
pathname: "/(tabs)/(availability)/availability-detail",
|
||||
params: { id: schedule.id.toString() },
|
||||
});
|
||||
};
|
||||
@@ -287,7 +287,7 @@ export function AvailabilityListScreen({
|
||||
|
||||
// Navigate to edit the newly created schedule
|
||||
router.push({
|
||||
pathname: "/availability-detail",
|
||||
pathname: "/(tabs)/(availability)/availability-detail",
|
||||
params: {
|
||||
id: newSchedule.id.toString(),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { DatePicker, Host } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, ScrollView, Switch, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import type { ScheduleAvailability } from "@/services/types";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// Map day names to numbers
|
||||
const DAY_NAME_TO_NUMBER: Record<string, number> = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
|
||||
// Parse time string to Date object (for DatePicker)
|
||||
const timeStringToDate = (timeStr: string): Date => {
|
||||
const [hours, minutes] = timeStr.split(":");
|
||||
const date = new Date();
|
||||
date.setHours(parseInt(hours, 10));
|
||||
date.setMinutes(parseInt(minutes || "0", 10));
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
return date;
|
||||
};
|
||||
|
||||
// Convert Date to time string (HH:mm)
|
||||
const dateToTimeString = (date: Date): string => {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// Parse availability from schedule for a specific day
|
||||
const parseAvailabilityForDay = (
|
||||
schedule: Schedule | null,
|
||||
dayIndex: number
|
||||
): ScheduleAvailability[] => {
|
||||
if (!schedule) return [];
|
||||
|
||||
const slots: ScheduleAvailability[] = [];
|
||||
const availabilityArray = schedule.availability;
|
||||
|
||||
if (availabilityArray && Array.isArray(availabilityArray)) {
|
||||
availabilityArray.forEach((slot) => {
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
|
||||
return DAY_NAME_TO_NUMBER[day];
|
||||
}
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
if (days.includes(dayIndex)) {
|
||||
const startTime = slot.startTime ?? "09:00:00";
|
||||
const endTime = slot.endTime ?? "17:00:00";
|
||||
slots.push({
|
||||
days: [dayIndex.toString()],
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
};
|
||||
|
||||
// Build full availability array from schedule, replacing the specific day
|
||||
const buildFullAvailability = (
|
||||
schedule: Schedule,
|
||||
dayIndex: number,
|
||||
newSlots: ScheduleAvailability[]
|
||||
): { days: string[]; startTime: string; endTime: string }[] => {
|
||||
const result: { days: string[]; startTime: string; endTime: string }[] = [];
|
||||
|
||||
// First, add all existing slots except for the target day
|
||||
const existingArray = schedule.availability;
|
||||
if (existingArray && Array.isArray(existingArray)) {
|
||||
existingArray.forEach((slot) => {
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
|
||||
return DAY_NAME_TO_NUMBER[day];
|
||||
}
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
// Remove the target day from this slot's days
|
||||
const otherDays = days.filter((d) => d !== dayIndex);
|
||||
if (otherDays.length > 0) {
|
||||
otherDays.forEach((d) => {
|
||||
result.push({
|
||||
days: [DAYS[d]],
|
||||
startTime: (slot.startTime ?? "09:00:00").substring(0, 5),
|
||||
endTime: (slot.endTime ?? "17:00:00").substring(0, 5),
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add the new slots for the target day
|
||||
newSlots.forEach((slot) => {
|
||||
result.push({
|
||||
days: [DAYS[dayIndex]],
|
||||
startTime: slot.startTime.substring(0, 5),
|
||||
endTime: slot.endTime.substring(0, 5),
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export interface EditAvailabilityDayScreenProps {
|
||||
schedule: Schedule | null;
|
||||
dayIndex: number;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface EditAvailabilityDayScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const EditAvailabilityDayScreen = forwardRef<
|
||||
EditAvailabilityDayScreenHandle,
|
||||
EditAvailabilityDayScreenProps
|
||||
>(function EditAvailabilityDayScreen(
|
||||
{ schedule, dayIndex, onSuccess, onSavingChange, transparentBackground = false },
|
||||
ref
|
||||
) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [slots, setSlots] = useState<{ startTime: Date; endTime: Date }[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const dayName = DAYS[dayIndex] || "Day";
|
||||
|
||||
// Initialize from schedule
|
||||
useEffect(() => {
|
||||
if (schedule) {
|
||||
const daySlots = parseAvailabilityForDay(schedule, dayIndex);
|
||||
if (daySlots.length > 0) {
|
||||
setIsEnabled(true);
|
||||
setSlots(
|
||||
daySlots.map((s) => ({
|
||||
startTime: timeStringToDate(s.startTime.substring(0, 5)),
|
||||
endTime: timeStringToDate(s.endTime.substring(0, 5)),
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setIsEnabled(false);
|
||||
setSlots([
|
||||
{
|
||||
startTime: timeStringToDate("09:00"),
|
||||
endTime: timeStringToDate("17:00"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, [schedule, dayIndex]);
|
||||
|
||||
// Notify parent of saving state
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
setIsEnabled(value);
|
||||
if (value && slots.length === 0) {
|
||||
setSlots([
|
||||
{
|
||||
startTime: timeStringToDate("09:00"),
|
||||
endTime: timeStringToDate("17:00"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[slots.length]
|
||||
);
|
||||
|
||||
const handleAddSlot = useCallback(() => {
|
||||
setSlots((prev) => [
|
||||
...prev,
|
||||
{
|
||||
startTime: timeStringToDate("09:00"),
|
||||
endTime: timeStringToDate("17:00"),
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveSlot = useCallback((index: number) => {
|
||||
setSlots((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleStartTimeChange = useCallback((index: number, date: Date) => {
|
||||
setSlots((prev) => {
|
||||
const newSlots = [...prev];
|
||||
newSlots[index] = { ...newSlots[index], startTime: date };
|
||||
return newSlots;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleEndTimeChange = useCallback((index: number, date: Date) => {
|
||||
setSlots((prev) => {
|
||||
const newSlots = [...prev];
|
||||
newSlots[index] = { ...newSlots[index], endTime: date };
|
||||
return newSlots;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!schedule || isSaving) return;
|
||||
|
||||
// Validate all slots have end time after start time
|
||||
// Compare time strings to avoid issues with Date object day components
|
||||
if (isEnabled) {
|
||||
for (const slot of slots) {
|
||||
const startTimeStr = dateToTimeString(slot.startTime);
|
||||
const endTimeStr = dateToTimeString(slot.endTime);
|
||||
if (endTimeStr <= startTimeStr) {
|
||||
Alert.alert("Error", "End time must be after start time for all slots");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build availability slots for this day
|
||||
const daySlots: ScheduleAvailability[] = isEnabled
|
||||
? slots.map((s) => ({
|
||||
days: [dayIndex.toString()],
|
||||
startTime: `${dateToTimeString(s.startTime)}:00`,
|
||||
endTime: `${dateToTimeString(s.endTime)}:00`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const fullAvailability = buildFullAvailability(schedule, dayIndex, daySlots);
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
availability: fullAvailability,
|
||||
});
|
||||
Alert.alert("Success", `${dayName} updated successfully`, [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to update schedule. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isSaving]);
|
||||
|
||||
// Expose submit to parent via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-[#8E8E93]">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className={`flex-1 ${backgroundStyle}`}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
>
|
||||
{/* Enable/Disable Toggle */}
|
||||
<View
|
||||
className={`mb-4 flex-row items-center justify-between rounded-xl px-4 py-3.5 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-[17px] font-medium text-black">Available on {dayName}</Text>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={handleToggle}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Slots */}
|
||||
{isEnabled && (
|
||||
<>
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Time Slots</Text>
|
||||
|
||||
{slots.map((slot, index) => (
|
||||
<View
|
||||
key={`slot-${slot.startTime}-${slot.endTime}-${index}`}
|
||||
className={`mb-3 rounded-xl px-4 py-3 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-[15px] font-medium text-[#8E8E93]">Slot {index + 1}</Text>
|
||||
{slots.length > 1 && (
|
||||
<AppPressable onPress={() => handleRemoveSlot(index)} className="p-1">
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
</AppPressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mt-3 flex-row items-center">
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-[13px] text-[#8E8E93]">Start</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={(date) => handleStartTimeChange(index, date)}
|
||||
displayedComponents={["hourAndMinute"]}
|
||||
selection={slot.startTime}
|
||||
/>
|
||||
</Host>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="mx-3 text-[17px] text-[#8E8E93]">–</Text>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-[13px] text-[#8E8E93]">End</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={(date) => handleEndTimeChange(index, date)}
|
||||
displayedComponents={["hourAndMinute"]}
|
||||
selection={slot.endTime}
|
||||
/>
|
||||
</Host>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add Slot Button */}
|
||||
<AppPressable
|
||||
onPress={handleAddSlot}
|
||||
className={`flex-row items-center justify-center rounded-xl py-3.5 ${
|
||||
transparentBackground
|
||||
? "border border-dashed border-gray-300/60 bg-white/40"
|
||||
: "border border-dashed border-[#C7C7CC] bg-white"
|
||||
}`}
|
||||
>
|
||||
<Ionicons name="add-circle" size={20} color="#007AFF" />
|
||||
<Text className="ml-2 text-[15px] font-medium text-[#007AFF]">Add Time Slot</Text>
|
||||
</AppPressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unavailable Message */}
|
||||
{!isEnabled && (
|
||||
<View
|
||||
className={`items-center rounded-xl px-4 py-8 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<Ionicons name="moon-outline" size={40} color="#8E8E93" />
|
||||
<Text className="mt-3 text-center text-[17px] text-[#8E8E93]">
|
||||
You are unavailable on {dayName}
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-[15px] text-[#C7C7CC]">
|
||||
Toggle on to set availability
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditAvailabilityDayScreen;
|
||||
@@ -0,0 +1,438 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, ScrollView, Switch, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import type { ScheduleAvailability } from "@/services/types";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// Map day names to numbers
|
||||
const DAY_NAME_TO_NUMBER: Record<string, number> = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
|
||||
// Generate time options (15-minute intervals)
|
||||
const generateTimeOptions = () => {
|
||||
const options: string[] = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const h = hour.toString().padStart(2, "0");
|
||||
const m = minute.toString().padStart(2, "0");
|
||||
options.push(`${h}:${m}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const TIME_OPTIONS = generateTimeOptions();
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return `${hour12}:${min} ${period}`;
|
||||
};
|
||||
|
||||
// Parse availability from schedule for a specific day
|
||||
const parseAvailabilityForDay = (
|
||||
schedule: Schedule | null,
|
||||
dayIndex: number
|
||||
): ScheduleAvailability[] => {
|
||||
if (!schedule) return [];
|
||||
|
||||
const slots: ScheduleAvailability[] = [];
|
||||
const availabilityArray = schedule.availability;
|
||||
|
||||
if (availabilityArray && Array.isArray(availabilityArray)) {
|
||||
availabilityArray.forEach((slot) => {
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
|
||||
return DAY_NAME_TO_NUMBER[day];
|
||||
}
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
if (days.includes(dayIndex)) {
|
||||
const startTime = slot.startTime ?? "09:00:00";
|
||||
const endTime = slot.endTime ?? "17:00:00";
|
||||
slots.push({
|
||||
days: [dayIndex.toString()],
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
};
|
||||
|
||||
// Build full availability array from schedule, replacing the specific day
|
||||
const buildFullAvailability = (
|
||||
schedule: Schedule,
|
||||
dayIndex: number,
|
||||
newSlots: ScheduleAvailability[]
|
||||
): { days: string[]; startTime: string; endTime: string }[] => {
|
||||
const result: { days: string[]; startTime: string; endTime: string }[] = [];
|
||||
|
||||
const existingArray = schedule.availability;
|
||||
if (existingArray && Array.isArray(existingArray)) {
|
||||
existingArray.forEach((slot) => {
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
|
||||
return DAY_NAME_TO_NUMBER[day];
|
||||
}
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
const otherDays = days.filter((d) => d !== dayIndex);
|
||||
if (otherDays.length > 0) {
|
||||
otherDays.forEach((d) => {
|
||||
result.push({
|
||||
days: [DAYS[d]],
|
||||
startTime: (slot.startTime ?? "09:00:00").substring(0, 5),
|
||||
endTime: (slot.endTime ?? "17:00:00").substring(0, 5),
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
newSlots.forEach((slot) => {
|
||||
result.push({
|
||||
days: [DAYS[dayIndex]],
|
||||
startTime: slot.startTime.substring(0, 5),
|
||||
endTime: slot.endTime.substring(0, 5),
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export interface EditAvailabilityDayScreenProps {
|
||||
schedule: Schedule | null;
|
||||
dayIndex: number;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface EditAvailabilityDayScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const EditAvailabilityDayScreen = forwardRef<
|
||||
EditAvailabilityDayScreenHandle,
|
||||
EditAvailabilityDayScreenProps
|
||||
>(function EditAvailabilityDayScreen({ schedule, dayIndex, onSuccess, onSavingChange }, ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [slots, setSlots] = useState<{ startTime: string; endTime: string }[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState<{
|
||||
slotIndex: number;
|
||||
type: "start" | "end";
|
||||
} | null>(null);
|
||||
|
||||
const dayName = DAYS[dayIndex] || "Day";
|
||||
|
||||
// Initialize from schedule
|
||||
useEffect(() => {
|
||||
if (schedule) {
|
||||
const daySlots = parseAvailabilityForDay(schedule, dayIndex);
|
||||
if (daySlots.length > 0) {
|
||||
setIsEnabled(true);
|
||||
setSlots(
|
||||
daySlots.map((s) => ({
|
||||
startTime: s.startTime.substring(0, 5),
|
||||
endTime: s.endTime.substring(0, 5),
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setIsEnabled(false);
|
||||
setSlots([{ startTime: "09:00", endTime: "17:00" }]);
|
||||
}
|
||||
}
|
||||
}, [schedule, dayIndex]);
|
||||
|
||||
// Notify parent of saving state
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
setIsEnabled(value);
|
||||
if (value && slots.length === 0) {
|
||||
setSlots([{ startTime: "09:00", endTime: "17:00" }]);
|
||||
}
|
||||
},
|
||||
[slots.length]
|
||||
);
|
||||
|
||||
const handleAddSlot = useCallback(() => {
|
||||
setSlots((prev) => [...prev, { startTime: "09:00", endTime: "17:00" }]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveSlot = useCallback((index: number) => {
|
||||
setSlots((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleTimeSelect = useCallback(
|
||||
(time: string) => {
|
||||
if (!showTimePicker) return;
|
||||
|
||||
setSlots((prev) => {
|
||||
const newSlots = [...prev];
|
||||
if (showTimePicker.type === "start") {
|
||||
newSlots[showTimePicker.slotIndex] = {
|
||||
...newSlots[showTimePicker.slotIndex],
|
||||
startTime: time,
|
||||
};
|
||||
} else {
|
||||
newSlots[showTimePicker.slotIndex] = {
|
||||
...newSlots[showTimePicker.slotIndex],
|
||||
endTime: time,
|
||||
};
|
||||
}
|
||||
return newSlots;
|
||||
});
|
||||
setShowTimePicker(null);
|
||||
},
|
||||
[showTimePicker]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!schedule || isSaving) return;
|
||||
|
||||
// Validate all slots have end time after start time
|
||||
if (isEnabled) {
|
||||
for (const slot of slots) {
|
||||
if (slot.endTime <= slot.startTime) {
|
||||
Alert.alert("Error", "End time must be after start time for all slots");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const daySlots: ScheduleAvailability[] = isEnabled
|
||||
? slots.map((s) => ({
|
||||
days: [dayIndex.toString()],
|
||||
startTime: `${s.startTime}:00`,
|
||||
endTime: `${s.endTime}:00`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const fullAvailability = buildFullAvailability(schedule, dayIndex, daySlots);
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
availability: fullAvailability,
|
||||
});
|
||||
Alert.alert("Success", `${dayName} updated successfully`, [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to update schedule. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isSaving]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<Text className="text-gray-500">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-white"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
{/* Enable/Disable Toggle */}
|
||||
<View className="mb-4 flex-row items-center justify-between rounded-lg border border-gray-200 px-4 py-3.5">
|
||||
<Text className="text-[17px] font-medium text-black">Available on {dayName}</Text>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={handleToggle}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Slots */}
|
||||
{isEnabled && (
|
||||
<>
|
||||
<Text className="mb-2 text-[13px] font-medium text-gray-500">Time Slots</Text>
|
||||
|
||||
{slots.map((slot, index) => (
|
||||
<View
|
||||
key={`slot-${slot.startTime}-${slot.endTime}-${index}`}
|
||||
className="mb-3 rounded-lg border border-gray-200 px-4 py-3"
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-[15px] font-medium text-gray-500">Slot {index + 1}</Text>
|
||||
{slots.length > 1 && (
|
||||
<AppPressable onPress={() => handleRemoveSlot(index)} className="p-1">
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
</AppPressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mt-3 flex-row items-center">
|
||||
<AppPressable
|
||||
className="flex-1 rounded-lg bg-gray-100 px-3 py-2"
|
||||
onPress={() => setShowTimePicker({ slotIndex: index, type: "start" })}
|
||||
>
|
||||
<Text className="text-[13px] text-gray-500">Start</Text>
|
||||
<Text className="text-[17px] text-black">{formatTime12Hour(slot.startTime)}</Text>
|
||||
</AppPressable>
|
||||
|
||||
<Text className="mx-3 text-[17px] text-gray-400">–</Text>
|
||||
|
||||
<AppPressable
|
||||
className="flex-1 rounded-lg bg-gray-100 px-3 py-2"
|
||||
onPress={() => setShowTimePicker({ slotIndex: index, type: "end" })}
|
||||
>
|
||||
<Text className="text-[13px] text-gray-500">End</Text>
|
||||
<Text className="text-[17px] text-black">{formatTime12Hour(slot.endTime)}</Text>
|
||||
</AppPressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add Slot Button */}
|
||||
<AppPressable
|
||||
onPress={handleAddSlot}
|
||||
className="flex-row items-center justify-center rounded-lg border border-dashed border-gray-300 py-3.5"
|
||||
>
|
||||
<Ionicons name="add-circle" size={20} color="#007AFF" />
|
||||
<Text className="ml-2 text-[15px] font-medium text-[#007AFF]">Add Time Slot</Text>
|
||||
</AppPressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unavailable Message */}
|
||||
{!isEnabled && (
|
||||
<View className="items-center rounded-lg border border-gray-200 px-4 py-8">
|
||||
<Ionicons name="moon-outline" size={40} color="#8E8E93" />
|
||||
<Text className="mt-3 text-center text-[17px] text-gray-500">
|
||||
You are unavailable on {dayName}
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-[15px] text-gray-400">
|
||||
Toggle on to set availability
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Time Picker Modal */}
|
||||
<FullScreenModal
|
||||
visible={!!showTimePicker}
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowTimePicker(null)}
|
||||
>
|
||||
<View className="flex-1 bg-white">
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<Text className="text-[17px] font-semibold">
|
||||
Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
|
||||
</Text>
|
||||
<AppPressable onPress={() => setShowTimePicker(null)}>
|
||||
<Ionicons name="close" size={24} color="#8E8E93" />
|
||||
</AppPressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{TIME_OPTIONS.map((time) => {
|
||||
const slotIndex = showTimePicker?.slotIndex ?? 0;
|
||||
const currentTime =
|
||||
showTimePicker?.type === "start"
|
||||
? slots[slotIndex]?.startTime
|
||||
: slots[slotIndex]?.endTime;
|
||||
const isSelected = currentTime === time;
|
||||
|
||||
return (
|
||||
<AppPressable
|
||||
key={time}
|
||||
onPress={() => handleTimeSelect(time)}
|
||||
className={`border-b border-gray-100 px-4 py-3.5 ${
|
||||
isSelected ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
isSelected ? "font-medium text-[#007AFF]" : "text-black"
|
||||
}`}
|
||||
>
|
||||
{formatTime12Hour(time)}
|
||||
</Text>
|
||||
{isSelected && <Ionicons name="checkmark" size={20} color="#007AFF" />}
|
||||
</View>
|
||||
</AppPressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditAvailabilityDayScreen;
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import type { ScheduleAvailability } from "@/services/types";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// Map day names to numbers
|
||||
const DAY_NAME_TO_NUMBER: Record<string, number> = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
const hour12Padded = hour12.toString().padStart(2, "0");
|
||||
return `${hour12Padded}:${min} ${period}`;
|
||||
};
|
||||
|
||||
// Parse availability from schedule
|
||||
const parseAvailability = (schedule: Schedule | null): Record<number, ScheduleAvailability[]> => {
|
||||
if (!schedule) return {};
|
||||
|
||||
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
|
||||
const availabilityArray = schedule.availability;
|
||||
|
||||
if (availabilityArray && Array.isArray(availabilityArray)) {
|
||||
availabilityArray.forEach((slot) => {
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
|
||||
return DAY_NAME_TO_NUMBER[day];
|
||||
}
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
days.forEach((day) => {
|
||||
if (!availabilityMap[day]) {
|
||||
availabilityMap[day] = [];
|
||||
}
|
||||
const startTime = slot.startTime ?? "09:00:00";
|
||||
const endTime = slot.endTime ?? "17:00:00";
|
||||
availabilityMap[day].push({
|
||||
days: [day.toString()],
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return availabilityMap;
|
||||
};
|
||||
|
||||
export interface EditAvailabilityHoursScreenProps {
|
||||
schedule: Schedule | null;
|
||||
onDayPress: (dayIndex: number) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export const EditAvailabilityHoursScreen = forwardRef<unknown, EditAvailabilityHoursScreenProps>(
|
||||
function EditAvailabilityHoursScreen(
|
||||
{ schedule, onDayPress, transparentBackground = false },
|
||||
_ref
|
||||
) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
|
||||
const availability = parseAvailability(schedule);
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-[#8E8E93]">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className={`flex-1 ${backgroundStyle}`}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
>
|
||||
<Text className="mb-3 px-1 text-[13px] font-medium text-[#8E8E93]">
|
||||
Tap a day to edit its hours
|
||||
</Text>
|
||||
|
||||
<View
|
||||
className={`overflow-hidden rounded-xl ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
{DAYS.map((day, dayIndex) => {
|
||||
const daySlots = availability[dayIndex] || [];
|
||||
const isEnabled = daySlots.length > 0;
|
||||
|
||||
return (
|
||||
<AppPressable
|
||||
key={day}
|
||||
className={`flex-row items-center px-4 py-3.5 ${
|
||||
dayIndex > 0 ? "border-t border-[#E5E5EA]" : ""
|
||||
}`}
|
||||
onPress={() => onDayPress(dayIndex)}
|
||||
>
|
||||
{/* Day status indicator */}
|
||||
<View
|
||||
className={`mr-3 h-2.5 w-2.5 rounded-full ${
|
||||
isEnabled ? "bg-[#34C759]" : "bg-[#E5E5EA]"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Day name */}
|
||||
<Text
|
||||
className={`w-24 text-[17px] font-medium ${
|
||||
isEnabled ? "text-black" : "text-[#8E8E93]"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
|
||||
{/* Time slots or unavailable */}
|
||||
<View className="flex-1">
|
||||
{isEnabled ? (
|
||||
daySlots.map((slot, slotIndex) => (
|
||||
<Text
|
||||
key={`${slotIndex}-${slot.startTime}`}
|
||||
className={`text-[15px] text-[#8E8E93] ${slotIndex > 0 ? "mt-0.5" : ""}`}
|
||||
>
|
||||
{formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text className="text-[15px] text-[#8E8E93]">Unavailable</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Chevron */}
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</AppPressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default EditAvailabilityHoursScreen;
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import type { ScheduleAvailability } from "@/services/types";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// Map day names to numbers
|
||||
const DAY_NAME_TO_NUMBER: Record<string, number> = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
const hour12Padded = hour12.toString().padStart(2, "0");
|
||||
return `${hour12Padded}:${min} ${period}`;
|
||||
};
|
||||
|
||||
// Parse availability from schedule
|
||||
const parseAvailability = (schedule: Schedule | null): Record<number, ScheduleAvailability[]> => {
|
||||
if (!schedule) return {};
|
||||
|
||||
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
|
||||
const availabilityArray = schedule.availability;
|
||||
|
||||
if (availabilityArray && Array.isArray(availabilityArray)) {
|
||||
availabilityArray.forEach((slot) => {
|
||||
let days: number[] = [];
|
||||
if (Array.isArray(slot.days)) {
|
||||
days = slot.days
|
||||
.map((day) => {
|
||||
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
|
||||
return DAY_NAME_TO_NUMBER[day];
|
||||
}
|
||||
if (typeof day === "string") {
|
||||
const parsed = parseInt(day, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof day === "number" && day >= 0 && day <= 6) {
|
||||
return day;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
}
|
||||
|
||||
days.forEach((day) => {
|
||||
if (!availabilityMap[day]) {
|
||||
availabilityMap[day] = [];
|
||||
}
|
||||
const startTime = slot.startTime ?? "09:00:00";
|
||||
const endTime = slot.endTime ?? "17:00:00";
|
||||
availabilityMap[day].push({
|
||||
days: [day.toString()],
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return availabilityMap;
|
||||
};
|
||||
|
||||
export interface EditAvailabilityHoursScreenProps {
|
||||
schedule: Schedule | null;
|
||||
onDayPress: (dayIndex: number) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export const EditAvailabilityHoursScreen = forwardRef<unknown, EditAvailabilityHoursScreenProps>(
|
||||
function EditAvailabilityHoursScreen({ schedule, onDayPress }, _ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const availability = parseAvailability(schedule);
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<Text className="text-gray-500">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-white"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<Text className="mb-3 text-[13px] font-medium text-gray-500">
|
||||
Tap a day to edit its hours
|
||||
</Text>
|
||||
|
||||
<View className="overflow-hidden rounded-lg border border-gray-200">
|
||||
{DAYS.map((day, dayIndex) => {
|
||||
const daySlots = availability[dayIndex] || [];
|
||||
const isEnabled = daySlots.length > 0;
|
||||
|
||||
return (
|
||||
<AppPressable
|
||||
key={day}
|
||||
className={`flex-row items-center px-4 py-3.5 ${
|
||||
dayIndex > 0 ? "border-t border-gray-200" : ""
|
||||
}`}
|
||||
onPress={() => onDayPress(dayIndex)}
|
||||
>
|
||||
{/* Day status indicator */}
|
||||
<View
|
||||
className={`mr-3 h-2.5 w-2.5 rounded-full ${
|
||||
isEnabled ? "bg-green-500" : "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Day name */}
|
||||
<Text
|
||||
className={`w-24 text-[17px] font-medium ${
|
||||
isEnabled ? "text-black" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
|
||||
{/* Time slots or unavailable */}
|
||||
<View className="flex-1">
|
||||
{isEnabled ? (
|
||||
daySlots.map((slot, slotIndex) => (
|
||||
<Text
|
||||
key={`${slotIndex}-${slot.startTime}`}
|
||||
className={`text-[15px] text-gray-500 ${slotIndex > 0 ? "mt-0.5" : ""}`}
|
||||
>
|
||||
{formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text className="text-[15px] text-gray-400">Unavailable</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Chevron */}
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</AppPressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default EditAvailabilityHoursScreen;
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { TIMEZONES as ALL_TIMEZONES } from "@/constants/timezones";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
// Format timezones for display
|
||||
const TIMEZONES = ALL_TIMEZONES.map((tz) => ({
|
||||
id: tz,
|
||||
label: tz.replace(/_/g, " "),
|
||||
}));
|
||||
|
||||
export interface EditAvailabilityNameScreenProps {
|
||||
schedule: Schedule | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface EditAvailabilityNameScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const EditAvailabilityNameScreen = forwardRef<
|
||||
EditAvailabilityNameScreenHandle,
|
||||
EditAvailabilityNameScreenProps
|
||||
>(function EditAvailabilityNameScreen(
|
||||
{ schedule, onSuccess, onSavingChange, transparentBackground = false },
|
||||
ref
|
||||
) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [timezone, setTimezone] = useState("UTC");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Initialize from schedule
|
||||
useEffect(() => {
|
||||
if (schedule) {
|
||||
setName(schedule.name ?? "");
|
||||
setTimezone(schedule.timeZone ?? "UTC");
|
||||
}
|
||||
}, [schedule]);
|
||||
|
||||
// Notify parent of saving state
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleTimezoneSelect = useCallback((tz: string) => {
|
||||
setTimezone(tz);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!schedule || isSaving) return;
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
Alert.alert("Error", "Please enter a schedule name");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
name: trimmedName,
|
||||
timeZone: timezone,
|
||||
});
|
||||
Alert.alert("Success", "Schedule updated successfully", [{ text: "OK", onPress: onSuccess }]);
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to update schedule. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [schedule, name, timezone, onSuccess, isSaving]);
|
||||
|
||||
// Expose submit to parent via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const selectedTimezoneLabel = TIMEZONES.find((tz) => tz.id === timezone)?.label || timezone;
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-[#8E8E93]">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" className={`flex-1 ${backgroundStyle}`}>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
>
|
||||
{transparentBackground ? (
|
||||
<>
|
||||
{/* Name Input - Glass UI */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Schedule Name</Text>
|
||||
<View className="mb-4 overflow-hidden rounded-xl border border-gray-300/40 bg-white/60">
|
||||
<TextInput
|
||||
className="px-4 py-3.5 text-[17px] text-black"
|
||||
placeholder="Enter schedule name"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoCapitalize="words"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Timezone Selector - Glass UI */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Timezone</Text>
|
||||
<View className="mb-4 flex-row items-center rounded-xl border border-gray-300/40 bg-white/60 px-4 py-3">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/20">
|
||||
<Ionicons name="globe-outline" size={20} color="#007AFF" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-black">{selectedTimezoneLabel}</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-[#8E8E93]">{timezone}</Text>
|
||||
</View>
|
||||
|
||||
{/* Native iOS Context Menu Button */}
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[
|
||||
buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"),
|
||||
padding(),
|
||||
]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<Button
|
||||
key={tz.id}
|
||||
systemImage={timezone === tz.id ? "checkmark" : "globe"}
|
||||
onPress={() => handleTimezoneSelect(tz.id)}
|
||||
label={tz.label}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image
|
||||
systemName="chevron.up.chevron.down"
|
||||
color="primary"
|
||||
size={16}
|
||||
modifiers={[frame({ height: 16, width: 16 })]}
|
||||
/>
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Name Input */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
|
||||
Schedule Name
|
||||
</Text>
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
<TextInput
|
||||
className="px-4 py-3.5 text-[17px] text-black"
|
||||
placeholder="Enter schedule name"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoCapitalize="words"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Timezone Selector */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
|
||||
Timezone
|
||||
</Text>
|
||||
<View className="mb-4 flex-row items-center rounded-xl bg-white px-4 py-3">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
|
||||
<Ionicons name="globe-outline" size={20} color="#007AFF" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[17px] font-medium text-black">{selectedTimezoneLabel}</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-[#8E8E93]">{timezone}</Text>
|
||||
</View>
|
||||
|
||||
{/* Native iOS Context Menu Button */}
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[
|
||||
buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"),
|
||||
padding(),
|
||||
]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<Button
|
||||
key={tz.id}
|
||||
systemImage={timezone === tz.id ? "checkmark" : "globe"}
|
||||
onPress={() => handleTimezoneSelect(tz.id)}
|
||||
label={tz.label}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image
|
||||
systemName="chevron.up.chevron.down"
|
||||
color="primary"
|
||||
size={16}
|
||||
modifiers={[frame({ height: 16, width: 16 })]}
|
||||
/>
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditAvailabilityNameScreen;
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import { TIMEZONES as ALL_TIMEZONES } from "@/constants/timezones";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
// Format timezones for display
|
||||
const TIMEZONES = ALL_TIMEZONES.map((tz) => ({
|
||||
id: tz,
|
||||
label: tz.replace(/_/g, " "),
|
||||
}));
|
||||
|
||||
export interface EditAvailabilityNameScreenProps {
|
||||
schedule: Schedule | null;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface EditAvailabilityNameScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const EditAvailabilityNameScreen = forwardRef<
|
||||
EditAvailabilityNameScreenHandle,
|
||||
EditAvailabilityNameScreenProps
|
||||
>(function EditAvailabilityNameScreen({ schedule, onSuccess, onSavingChange }, ref) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [timezone, setTimezone] = useState("UTC");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showTimezoneModal, setShowTimezoneModal] = useState(false);
|
||||
|
||||
// Initialize from schedule
|
||||
useEffect(() => {
|
||||
if (schedule) {
|
||||
setName(schedule.name ?? "");
|
||||
setTimezone(schedule.timeZone ?? "UTC");
|
||||
}
|
||||
}, [schedule]);
|
||||
|
||||
// Notify parent of saving state
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!schedule || isSaving) return;
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
Alert.alert("Error", "Please enter a schedule name");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
name: trimmedName,
|
||||
timeZone: timezone,
|
||||
});
|
||||
Alert.alert("Success", "Schedule updated successfully", [{ text: "OK", onPress: onSuccess }]);
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to update schedule. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [schedule, name, timezone, onSuccess, isSaving]);
|
||||
|
||||
// Expose submit to parent via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const selectedTimezoneLabel = TIMEZONES.find((tz) => tz.id === timezone)?.label || timezone;
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<Text className="text-gray-500">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" className="flex-1 bg-white">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Name Input */}
|
||||
<Text className="mb-2 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Schedule Name
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mb-4 rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 text-[17px] text-black"
|
||||
placeholder="Enter schedule name"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoCapitalize="words"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
|
||||
{/* Timezone Selector */}
|
||||
<Text className="mb-2 text-[13px] font-medium uppercase tracking-wide text-gray-500">
|
||||
Timezone
|
||||
</Text>
|
||||
<AppPressable
|
||||
className="flex-row items-center justify-between rounded-lg border border-gray-300 bg-gray-50 px-4 py-3"
|
||||
onPress={() => setShowTimezoneModal(true)}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="globe-outline" size={20} color="#007AFF" style={{ marginRight: 12 }} />
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">{selectedTimezoneLabel}</Text>
|
||||
<Text className="mt-0.5 text-[13px] text-gray-500">{timezone}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</AppPressable>
|
||||
</ScrollView>
|
||||
|
||||
{/* Timezone Modal */}
|
||||
<FullScreenModal
|
||||
visible={showTimezoneModal}
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowTimezoneModal(false)}
|
||||
>
|
||||
<View className="flex-1 bg-white">
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<Text className="text-[17px] font-semibold">Select Timezone</Text>
|
||||
<AppPressable onPress={() => setShowTimezoneModal(false)}>
|
||||
<Ionicons name="close" size={24} color="#8E8E93" />
|
||||
</AppPressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<AppPressable
|
||||
key={tz.id}
|
||||
onPress={() => {
|
||||
setTimezone(tz.id);
|
||||
setShowTimezoneModal(false);
|
||||
}}
|
||||
className={`border-b border-gray-100 px-4 py-3.5 ${
|
||||
tz.id === timezone ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View>
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
tz.id === timezone ? "font-medium text-[#007AFF]" : "text-black"
|
||||
}`}
|
||||
>
|
||||
{tz.label}
|
||||
</Text>
|
||||
<Text className="text-[13px] text-gray-500">{tz.id}</Text>
|
||||
</View>
|
||||
{tz.id === timezone && <Ionicons name="checkmark" size={20} color="#007AFF" />}
|
||||
</View>
|
||||
</AppPressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditAvailabilityNameScreen;
|
||||
@@ -0,0 +1,432 @@
|
||||
import { DatePicker, Host } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, Pressable, ScrollView, Switch, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return `${hour12}:${min} ${period}`;
|
||||
};
|
||||
|
||||
// Parse time string to Date object (for DatePicker)
|
||||
const timeStringToDate = (timeStr: string): Date => {
|
||||
const [hours, minutes] = timeStr.split(":");
|
||||
const date = new Date();
|
||||
date.setHours(parseInt(hours, 10));
|
||||
date.setMinutes(parseInt(minutes || "0", 10));
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
return date;
|
||||
};
|
||||
|
||||
// Convert Date to time string (HH:mm)
|
||||
const dateToTimeString = (date: Date): string => {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// Convert Date to date string (YYYY-MM-DD)
|
||||
const dateToDateString = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Parse date string to Date object
|
||||
const dateStringToDate = (dateStr: string): Date => {
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDateForDisplay = (date: Date): string => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export interface EditAvailabilityOverrideScreenProps {
|
||||
schedule: Schedule | null;
|
||||
overrideIndex?: number;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
onEditOverride?: (index: number) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface EditAvailabilityOverrideScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
EditAvailabilityOverrideScreenHandle,
|
||||
EditAvailabilityOverrideScreenProps
|
||||
>(function EditAvailabilityOverrideScreen(
|
||||
{
|
||||
schedule,
|
||||
overrideIndex,
|
||||
onSuccess,
|
||||
onSavingChange,
|
||||
onEditOverride,
|
||||
transparentBackground = false,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
|
||||
const isEditing = overrideIndex !== undefined;
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [isUnavailable, setIsUnavailable] = useState(false);
|
||||
const [startTime, setStartTime] = useState<Date>(timeStringToDate("09:00"));
|
||||
const [endTime, setEndTime] = useState<Date>(timeStringToDate("17:00"));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Initialize from existing override if editing
|
||||
useEffect(() => {
|
||||
if (schedule && isEditing && schedule.overrides) {
|
||||
const override = schedule.overrides[overrideIndex];
|
||||
if (override) {
|
||||
setSelectedDate(dateStringToDate(override.date ?? ""));
|
||||
const start = override.startTime ?? "00:00";
|
||||
const end = override.endTime ?? "00:00";
|
||||
setIsUnavailable(start === "00:00" && end === "00:00");
|
||||
setStartTime(timeStringToDate(start === "00:00" ? "09:00" : start));
|
||||
setEndTime(timeStringToDate(end === "00:00" ? "17:00" : end));
|
||||
}
|
||||
}
|
||||
}, [schedule, isEditing, overrideIndex]);
|
||||
|
||||
// Notify parent of saving state
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleDateChange = useCallback((date: Date) => {
|
||||
setSelectedDate(date);
|
||||
}, []);
|
||||
|
||||
const handleStartTimeChange = useCallback((date: Date) => {
|
||||
setStartTime(date);
|
||||
}, []);
|
||||
|
||||
const handleEndTimeChange = useCallback((date: Date) => {
|
||||
setEndTime(date);
|
||||
}, []);
|
||||
|
||||
const saveOverrides = useCallback(
|
||||
async (
|
||||
newOverrides: { date: string; startTime: string; endTime: string }[],
|
||||
successMessage: string
|
||||
) => {
|
||||
if (!schedule) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
overrides: newOverrides,
|
||||
});
|
||||
Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]);
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to save override. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[schedule, onSuccess]
|
||||
);
|
||||
|
||||
const handleDeleteOverride = useCallback(
|
||||
(indexToDelete: number) => {
|
||||
if (!schedule || !schedule.overrides) return;
|
||||
|
||||
const override = schedule.overrides[indexToDelete];
|
||||
const dateDisplay = formatDateForDisplay(dateStringToDate(override?.date ?? ""));
|
||||
|
||||
Alert.alert(
|
||||
"Delete Override",
|
||||
`Are you sure you want to delete the override for ${dateDisplay}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
const newOverrides = schedule.overrides
|
||||
? schedule.overrides
|
||||
.filter((_, idx) => idx !== indexToDelete)
|
||||
.map((o) => ({
|
||||
date: o.date ?? "",
|
||||
startTime: (o.startTime ?? "00:00").substring(0, 5),
|
||||
endTime: (o.endTime ?? "00:00").substring(0, 5),
|
||||
}))
|
||||
: [];
|
||||
await saveOverrides(newOverrides, "Override deleted successfully");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
},
|
||||
[schedule, saveOverrides]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!schedule || isSaving) return;
|
||||
|
||||
const dateStr = dateToDateString(selectedDate);
|
||||
|
||||
// Validate end time is after start time (only when not marking as unavailable)
|
||||
// Compare time strings to avoid issues with Date object day components
|
||||
const startTimeStr = dateToTimeString(startTime);
|
||||
const endTimeStr = dateToTimeString(endTime);
|
||||
if (!isUnavailable && endTimeStr <= startTimeStr) {
|
||||
Alert.alert("Error", "End time must be after start time");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the new override
|
||||
const newOverride = {
|
||||
date: dateStr,
|
||||
startTime: isUnavailable ? "00:00" : dateToTimeString(startTime),
|
||||
endTime: isUnavailable ? "00:00" : dateToTimeString(endTime),
|
||||
};
|
||||
|
||||
// Build new overrides array
|
||||
let newOverrides: { date: string; startTime: string; endTime: string }[] = [];
|
||||
|
||||
if (schedule.overrides && Array.isArray(schedule.overrides)) {
|
||||
newOverrides = schedule.overrides.map((o) => ({
|
||||
date: o.date ?? "",
|
||||
startTime: (o.startTime ?? "00:00").substring(0, 5),
|
||||
endTime: (o.endTime ?? "00:00").substring(0, 5),
|
||||
}));
|
||||
}
|
||||
|
||||
const successMessage = isEditing
|
||||
? "Override updated successfully"
|
||||
: "Override added successfully";
|
||||
|
||||
if (isEditing && overrideIndex !== undefined) {
|
||||
// Update existing override
|
||||
newOverrides[overrideIndex] = newOverride;
|
||||
} else {
|
||||
// Check if date already exists
|
||||
const existingIndex = newOverrides.findIndex((o) => o.date === dateStr);
|
||||
if (existingIndex >= 0) {
|
||||
Alert.alert(
|
||||
"Date Already Exists",
|
||||
"An override for this date already exists. Do you want to replace it?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Replace",
|
||||
onPress: async () => {
|
||||
newOverrides[existingIndex] = newOverride;
|
||||
await saveOverrides(newOverrides, "Override replaced successfully");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Add new override
|
||||
newOverrides.push(newOverride);
|
||||
}
|
||||
|
||||
await saveOverrides(newOverrides, successMessage);
|
||||
}, [
|
||||
schedule,
|
||||
selectedDate,
|
||||
isUnavailable,
|
||||
startTime,
|
||||
endTime,
|
||||
isEditing,
|
||||
overrideIndex,
|
||||
saveOverrides,
|
||||
isSaving,
|
||||
]);
|
||||
|
||||
// Expose submit to parent via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-[#8E8E93]">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className={`flex-1 ${backgroundStyle}`}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
>
|
||||
{/* Date Picker */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Date</Text>
|
||||
<View
|
||||
className={`mb-4 rounded-xl px-4 py-3.5 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
|
||||
<Ionicons name="calendar" size={20} color="#007AFF" />
|
||||
</View>
|
||||
<Text className="text-[17px] text-black">{formatDateForDisplay(selectedDate)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="mt-3">
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={handleDateChange}
|
||||
displayedComponents={["date"]}
|
||||
selection={selectedDate}
|
||||
/>
|
||||
</Host>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Unavailable Toggle */}
|
||||
<View
|
||||
className={`mb-4 flex-row items-center justify-between rounded-xl px-4 py-3.5 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#FF3B30]/10">
|
||||
<Ionicons name="moon-outline" size={20} color="#FF3B30" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">Mark as unavailable</Text>
|
||||
<Text className="text-[13px] text-[#8E8E93]">Block this entire day</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={isUnavailable}
|
||||
onValueChange={setIsUnavailable}
|
||||
trackColor={{ false: "#E5E5EA", true: "#FF3B30" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Range (only when available) */}
|
||||
{!isUnavailable && (
|
||||
<>
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Available Hours</Text>
|
||||
<View
|
||||
className={`mb-4 rounded-xl px-4 py-3.5 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-[13px] text-[#8E8E93]">Start Time</Text>
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={handleStartTimeChange}
|
||||
displayedComponents={["hourAndMinute"]}
|
||||
selection={startTime}
|
||||
/>
|
||||
</Host>
|
||||
</View>
|
||||
|
||||
<Text className="mx-4 text-[17px] text-[#8E8E93]">–</Text>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-[13px] text-[#8E8E93]">End Time</Text>
|
||||
<Host matchContents>
|
||||
<DatePicker
|
||||
onDateChange={handleEndTimeChange}
|
||||
displayedComponents={["hourAndMinute"]}
|
||||
selection={endTime}
|
||||
/>
|
||||
</Host>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Existing Overrides (if any) */}
|
||||
{!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
|
||||
<>
|
||||
<Text className="mb-2 mt-4 px-1 text-[13px] font-medium text-[#8E8E93]">
|
||||
Existing Overrides
|
||||
</Text>
|
||||
<View
|
||||
className={`overflow-hidden rounded-xl ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
{schedule.overrides.map((override, index) => (
|
||||
<Pressable
|
||||
key={override.date}
|
||||
className={`flex-row items-center justify-between px-4 py-3 ${
|
||||
index > 0 ? "border-t border-[#E5E5EA]" : ""
|
||||
}`}
|
||||
onPress={() => onEditOverride?.(index)}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[15px] text-black">
|
||||
{formatDateForDisplay(dateStringToDate(override.date ?? ""))}
|
||||
</Text>
|
||||
{override.startTime === "00:00" && override.endTime === "00:00" ? (
|
||||
<Text className="text-[13px] text-[#FF3B30]">Unavailable</Text>
|
||||
) : (
|
||||
<Text className="text-[13px] text-[#8E8E93]">
|
||||
{formatTime12Hour(override.startTime ?? "00:00")} –{" "}
|
||||
{formatTime12Hour(override.endTime ?? "00:00")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Pressable
|
||||
className="rounded-full bg-[#FF3B30]/10 p-2"
|
||||
onPress={() => handleDeleteOverride(index)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
className="ml-3 rounded-full bg-[#8E8E93]/10 p-2"
|
||||
onPress={() => onEditOverride?.(index)}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={18} color="#8E8E93" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditAvailabilityOverrideScreen;
|
||||
@@ -0,0 +1,434 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, ScrollView, Switch, Text, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
// Generate time options (15-minute intervals)
|
||||
const generateTimeOptions = () => {
|
||||
const options: string[] = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const h = hour.toString().padStart(2, "0");
|
||||
const m = minute.toString().padStart(2, "0");
|
||||
options.push(`${h}:${m}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const TIME_OPTIONS = generateTimeOptions();
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return `${hour12}:${min} ${period}`;
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDateForDisplay = (dateStr: string): string => {
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(`${dateStr}T00:00:00`);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export interface EditAvailabilityOverrideScreenProps {
|
||||
schedule: Schedule | null;
|
||||
overrideIndex?: number;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
onEditOverride?: (index: number) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface EditAvailabilityOverrideScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
EditAvailabilityOverrideScreenHandle,
|
||||
EditAvailabilityOverrideScreenProps
|
||||
>(function EditAvailabilityOverrideScreen(
|
||||
{ schedule, overrideIndex, onSuccess, onSavingChange, onEditOverride },
|
||||
ref
|
||||
) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const isEditing = overrideIndex !== undefined;
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState("");
|
||||
const [isUnavailable, setIsUnavailable] = useState(false);
|
||||
const [startTime, setStartTime] = useState("09:00");
|
||||
const [endTime, setEndTime] = useState("17:00");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState<{
|
||||
type: "start" | "end";
|
||||
} | null>(null);
|
||||
|
||||
// Initialize from existing override if editing
|
||||
useEffect(() => {
|
||||
if (schedule && isEditing && schedule.overrides && overrideIndex !== undefined) {
|
||||
const override = schedule.overrides[overrideIndex];
|
||||
if (override) {
|
||||
setSelectedDate(override.date ?? "");
|
||||
const start = override.startTime ?? "00:00";
|
||||
const end = override.endTime ?? "00:00";
|
||||
setIsUnavailable(start === "00:00" && end === "00:00");
|
||||
setStartTime(start === "00:00" ? "09:00" : start);
|
||||
setEndTime(end === "00:00" ? "17:00" : end);
|
||||
}
|
||||
}
|
||||
}, [schedule, isEditing, overrideIndex]);
|
||||
|
||||
// Notify parent of saving state
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleTimeSelect = useCallback(
|
||||
(time: string) => {
|
||||
if (!showTimePicker) return;
|
||||
|
||||
if (showTimePicker.type === "start") {
|
||||
setStartTime(time);
|
||||
} else {
|
||||
setEndTime(time);
|
||||
}
|
||||
setShowTimePicker(null);
|
||||
},
|
||||
[showTimePicker]
|
||||
);
|
||||
|
||||
const saveOverrides = useCallback(
|
||||
async (
|
||||
newOverrides: { date: string; startTime: string; endTime: string }[],
|
||||
successMessage: string
|
||||
) => {
|
||||
if (!schedule) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
overrides: newOverrides,
|
||||
});
|
||||
Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]);
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to save override. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[schedule, onSuccess]
|
||||
);
|
||||
|
||||
const handleDeleteOverride = useCallback(
|
||||
(indexToDelete: number) => {
|
||||
if (!schedule || !schedule.overrides) return;
|
||||
|
||||
const override = schedule.overrides[indexToDelete];
|
||||
const dateDisplay = formatDateForDisplay(override?.date ?? "");
|
||||
|
||||
Alert.alert(
|
||||
"Delete Override",
|
||||
`Are you sure you want to delete the override for ${dateDisplay}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
const newOverrides = schedule.overrides
|
||||
? schedule.overrides
|
||||
.filter((_, idx) => idx !== indexToDelete)
|
||||
.map((o) => ({
|
||||
date: o.date ?? "",
|
||||
startTime: (o.startTime ?? "00:00").substring(0, 5),
|
||||
endTime: (o.endTime ?? "00:00").substring(0, 5),
|
||||
}))
|
||||
: [];
|
||||
await saveOverrides(newOverrides, "Override deleted successfully");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
},
|
||||
[schedule, saveOverrides]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!schedule || isSaving) return;
|
||||
|
||||
if (!selectedDate) {
|
||||
Alert.alert("Error", "Please enter a date (YYYY-MM-DD format)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(selectedDate)) {
|
||||
Alert.alert("Error", "Please enter date in YYYY-MM-DD format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate end time is after start time (only when not marking as unavailable)
|
||||
if (!isUnavailable && endTime <= startTime) {
|
||||
Alert.alert("Error", "End time must be after start time");
|
||||
return;
|
||||
}
|
||||
|
||||
const newOverride = {
|
||||
date: selectedDate,
|
||||
startTime: isUnavailable ? "00:00" : startTime,
|
||||
endTime: isUnavailable ? "00:00" : endTime,
|
||||
};
|
||||
|
||||
let newOverrides: { date: string; startTime: string; endTime: string }[] = [];
|
||||
|
||||
if (schedule.overrides && Array.isArray(schedule.overrides)) {
|
||||
newOverrides = schedule.overrides.map((o) => ({
|
||||
date: o.date ?? "",
|
||||
startTime: (o.startTime ?? "00:00").substring(0, 5),
|
||||
endTime: (o.endTime ?? "00:00").substring(0, 5),
|
||||
}));
|
||||
}
|
||||
|
||||
const successMessage = isEditing
|
||||
? "Override updated successfully"
|
||||
: "Override added successfully";
|
||||
|
||||
if (isEditing && overrideIndex !== undefined) {
|
||||
newOverrides[overrideIndex] = newOverride;
|
||||
} else {
|
||||
const existingIndex = newOverrides.findIndex((o) => o.date === selectedDate);
|
||||
if (existingIndex >= 0) {
|
||||
Alert.alert(
|
||||
"Date Already Exists",
|
||||
"An override for this date already exists. Do you want to replace it?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Replace",
|
||||
onPress: async () => {
|
||||
newOverrides[existingIndex] = newOverride;
|
||||
await saveOverrides(newOverrides, "Override replaced successfully");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
newOverrides.push(newOverride);
|
||||
}
|
||||
|
||||
await saveOverrides(newOverrides, successMessage);
|
||||
}, [
|
||||
schedule,
|
||||
selectedDate,
|
||||
isUnavailable,
|
||||
startTime,
|
||||
endTime,
|
||||
isEditing,
|
||||
overrideIndex,
|
||||
saveOverrides,
|
||||
isSaving,
|
||||
]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<Text className="text-gray-500">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-white"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
{/* Date Input */}
|
||||
<Text className="mb-2 text-[13px] font-medium text-gray-500">Date (YYYY-MM-DD)</Text>
|
||||
<View className="mb-4 rounded-lg border border-gray-200 px-4 py-3">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="calendar" size={20} color="#007AFF" style={{ marginRight: 12 }} />
|
||||
<TextInput
|
||||
className="flex-1 text-[17px] text-black"
|
||||
placeholder="2024-12-25"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={selectedDate}
|
||||
onChangeText={setSelectedDate}
|
||||
/>
|
||||
</View>
|
||||
{selectedDate && (
|
||||
<Text className="mt-2 text-[15px] text-gray-500">
|
||||
{formatDateForDisplay(selectedDate)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Unavailable Toggle */}
|
||||
<View className="mb-4 flex-row items-center justify-between rounded-lg border border-gray-200 px-4 py-3.5">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="moon-outline" size={20} color="#FF3B30" style={{ marginRight: 12 }} />
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">Mark as unavailable</Text>
|
||||
<Text className="text-[13px] text-gray-500">Block this entire day</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={isUnavailable}
|
||||
onValueChange={setIsUnavailable}
|
||||
trackColor={{ false: "#E5E5EA", true: "#FF3B30" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Range (only when available) */}
|
||||
{!isUnavailable && (
|
||||
<>
|
||||
<Text className="mb-2 text-[13px] font-medium text-gray-500">Available Hours</Text>
|
||||
<View className="mb-4 rounded-lg border border-gray-200 px-4 py-3.5">
|
||||
<View className="flex-row items-center">
|
||||
<AppPressable
|
||||
className="flex-1 rounded-lg bg-gray-100 px-3 py-2"
|
||||
onPress={() => setShowTimePicker({ type: "start" })}
|
||||
>
|
||||
<Text className="text-[13px] text-gray-500">Start Time</Text>
|
||||
<Text className="text-[17px] text-black">{formatTime12Hour(startTime)}</Text>
|
||||
</AppPressable>
|
||||
|
||||
<Text className="mx-4 text-[17px] text-gray-400">–</Text>
|
||||
|
||||
<AppPressable
|
||||
className="flex-1 rounded-lg bg-gray-100 px-3 py-2"
|
||||
onPress={() => setShowTimePicker({ type: "end" })}
|
||||
>
|
||||
<Text className="text-[13px] text-gray-500">End Time</Text>
|
||||
<Text className="text-[17px] text-black">{formatTime12Hour(endTime)}</Text>
|
||||
</AppPressable>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Existing Overrides */}
|
||||
{!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
|
||||
<>
|
||||
<Text className="mb-2 mt-4 text-[13px] font-medium text-gray-500">
|
||||
Existing Overrides
|
||||
</Text>
|
||||
<View className="overflow-hidden rounded-lg border border-gray-200">
|
||||
{schedule.overrides.map((override, index) => (
|
||||
<AppPressable
|
||||
key={override.date}
|
||||
className={`flex-row items-center justify-between px-4 py-3 ${
|
||||
index > 0 ? "border-t border-gray-200" : ""
|
||||
}`}
|
||||
onPress={() => onEditOverride?.(index)}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[15px] text-black">
|
||||
{formatDateForDisplay(override.date ?? "")}
|
||||
</Text>
|
||||
{override.startTime === "00:00" && override.endTime === "00:00" ? (
|
||||
<Text className="text-[13px] text-red-500">Unavailable</Text>
|
||||
) : (
|
||||
<Text className="text-[13px] text-gray-500">
|
||||
{formatTime12Hour(override.startTime ?? "00:00")} –{" "}
|
||||
{formatTime12Hour(override.endTime ?? "00:00")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<AppPressable
|
||||
className="rounded-full bg-red-100 p-2"
|
||||
onPress={() => handleDeleteOverride(index)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
|
||||
</AppPressable>
|
||||
<AppPressable
|
||||
className="ml-3 rounded-full bg-gray-100 p-2"
|
||||
onPress={() => onEditOverride?.(index)}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={18} color="#8E8E93" />
|
||||
</AppPressable>
|
||||
</View>
|
||||
</AppPressable>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Time Picker Modal */}
|
||||
<FullScreenModal
|
||||
visible={!!showTimePicker}
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowTimePicker(null)}
|
||||
>
|
||||
<View className="flex-1 bg-white">
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<Text className="text-[17px] font-semibold">
|
||||
Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
|
||||
</Text>
|
||||
<AppPressable onPress={() => setShowTimePicker(null)}>
|
||||
<Ionicons name="close" size={24} color="#8E8E93" />
|
||||
</AppPressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{TIME_OPTIONS.map((time) => {
|
||||
const currentTime = showTimePicker?.type === "start" ? startTime : endTime;
|
||||
const isSelected = currentTime === time;
|
||||
|
||||
return (
|
||||
<AppPressable
|
||||
key={time}
|
||||
onPress={() => handleTimeSelect(time)}
|
||||
className={`border-b border-gray-100 px-4 py-3.5 ${
|
||||
isSelected ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
isSelected ? "font-medium text-[#007AFF]" : "text-black"
|
||||
}`}
|
||||
>
|
||||
{formatTime12Hour(time)}
|
||||
</Text>
|
||||
{isSelected && <Ionicons name="checkmark" size={20} color="#007AFF" />}
|
||||
</View>
|
||||
</AppPressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditAvailabilityOverrideScreen;
|
||||
@@ -125,7 +125,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
@@ -171,7 +171,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
Alert.alert("Error", "Failed to update location. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedType, inputValue, onSuccess]);
|
||||
}, [booking, selectedType, inputValue, onSuccess, isSaving]);
|
||||
|
||||
// Expose submit function to parent via ref
|
||||
useImperativeHandle(
|
||||
|
||||
@@ -131,7 +131,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
const selectedTypeConfig = LOCATION_TYPES[selectedType];
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
@@ -176,7 +176,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
Alert.alert("Error", "Failed to update location. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedType, inputValue, onSuccess]);
|
||||
}, [booking, selectedType, inputValue, onSuccess, isSaving]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
||||
@@ -69,7 +69,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
if (selectedDateTime <= new Date()) {
|
||||
Alert.alert("Error", "Please select a future date and time");
|
||||
@@ -92,7 +92,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedDateTime, reason, onSuccess]);
|
||||
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
|
||||
|
||||
@@ -53,7 +53,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
if (selectedDateTime <= new Date()) {
|
||||
Alert.alert("Error", "Please select a future date and time");
|
||||
@@ -75,7 +75,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedDateTime, reason, onSuccess]);
|
||||
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
|
||||
|
||||
const handleDateSelected = useCallback(
|
||||
(date: Date) => {
|
||||
|
||||
@@ -70,7 +70,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!booking) return;
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
// Validate the date is in the future
|
||||
if (selectedDateTime <= new Date()) {
|
||||
@@ -98,7 +98,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [booking, selectedDateTime, reason, onSuccess]);
|
||||
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
|
||||
|
||||
// Helper function to format date as YYYY-MM-DD in local timezone (avoids UTC conversion issues)
|
||||
const formatLocalDate = (date: Date) => {
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
// Timezone constants for the companion app
|
||||
// Source: cal.com/packages/lib/timeZones.ts
|
||||
|
||||
export const TIMEZONES = [
|
||||
"Africa/Abidjan",
|
||||
"Africa/Accra",
|
||||
"Africa/Addis_Ababa",
|
||||
"Africa/Algiers",
|
||||
"Africa/Asmera",
|
||||
"Africa/Bamako",
|
||||
"Africa/Bangui",
|
||||
"Africa/Banjul",
|
||||
"Africa/Bissau",
|
||||
"Africa/Blantyre",
|
||||
"Africa/Brazzaville",
|
||||
"Africa/Bujumbura",
|
||||
"Africa/Cairo",
|
||||
"Africa/Casablanca",
|
||||
"Africa/Ceuta",
|
||||
"Africa/Conakry",
|
||||
"Africa/Dakar",
|
||||
"Africa/Dar_es_Salaam",
|
||||
"Africa/Djibouti",
|
||||
"Africa/Douala",
|
||||
"Africa/El_Aaiun",
|
||||
"Africa/Freetown",
|
||||
"Africa/Gaborone",
|
||||
"Africa/Harare",
|
||||
"Africa/Johannesburg",
|
||||
"Africa/Juba",
|
||||
"Africa/Kampala",
|
||||
"Africa/Khartoum",
|
||||
"Africa/Kigali",
|
||||
"Africa/Kinshasa",
|
||||
"Africa/Lagos",
|
||||
"Africa/Libreville",
|
||||
"Africa/Lome",
|
||||
"Africa/Luanda",
|
||||
"Africa/Lubumbashi",
|
||||
"Africa/Lusaka",
|
||||
"Africa/Malabo",
|
||||
"Africa/Maputo",
|
||||
"Africa/Maseru",
|
||||
"Africa/Mbabane",
|
||||
"Africa/Mogadishu",
|
||||
"Africa/Monrovia",
|
||||
"Africa/Nairobi",
|
||||
"Africa/Ndjamena",
|
||||
"Africa/Niamey",
|
||||
"Africa/Nouakchott",
|
||||
"Africa/Ouagadougou",
|
||||
"Africa/Porto-Novo",
|
||||
"Africa/Sao_Tome",
|
||||
"Africa/Tripoli",
|
||||
"Africa/Tunis",
|
||||
"Africa/Windhoek",
|
||||
"America/Adak",
|
||||
"America/Anchorage",
|
||||
"America/Anguilla",
|
||||
"America/Antigua",
|
||||
"America/Araguaina",
|
||||
"America/Argentina/La_Rioja",
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
"America/Argentina/Salta",
|
||||
"America/Argentina/San_Juan",
|
||||
"America/Argentina/San_Luis",
|
||||
"America/Argentina/Tucuman",
|
||||
"America/Argentina/Ushuaia",
|
||||
"America/Aruba",
|
||||
"America/Asuncion",
|
||||
"America/Bahia",
|
||||
"America/Bahia_Banderas",
|
||||
"America/Barbados",
|
||||
"America/Belem",
|
||||
"America/Belize",
|
||||
"America/Blanc-Sablon",
|
||||
"America/Boa_Vista",
|
||||
"America/Bogota",
|
||||
"America/Boise",
|
||||
"America/Buenos_Aires",
|
||||
"America/Cambridge_Bay",
|
||||
"America/Campo_Grande",
|
||||
"America/Cancun",
|
||||
"America/Caracas",
|
||||
"America/Catamarca",
|
||||
"America/Cayenne",
|
||||
"America/Cayman",
|
||||
"America/Chicago",
|
||||
"America/Chihuahua",
|
||||
"America/Ciudad_Juarez",
|
||||
"America/Coral_Harbour",
|
||||
"America/Cordoba",
|
||||
"America/Costa_Rica",
|
||||
"America/Creston",
|
||||
"America/Cuiaba",
|
||||
"America/Curacao",
|
||||
"America/Danmarkshavn",
|
||||
"America/Dawson",
|
||||
"America/Dawson_Creek",
|
||||
"America/Denver",
|
||||
"America/Detroit",
|
||||
"America/Dominica",
|
||||
"America/Edmonton",
|
||||
"America/Eirunepe",
|
||||
"America/El_Salvador",
|
||||
"America/Fort_Nelson",
|
||||
"America/Fortaleza",
|
||||
"America/Glace_Bay",
|
||||
"America/Godthab",
|
||||
"America/Goose_Bay",
|
||||
"America/Grand_Turk",
|
||||
"America/Grenada",
|
||||
"America/Guadeloupe",
|
||||
"America/Guatemala",
|
||||
"America/Guayaquil",
|
||||
"America/Guyana",
|
||||
"America/Halifax",
|
||||
"America/Havana",
|
||||
"America/Hermosillo",
|
||||
"America/Indiana/Knox",
|
||||
"America/Indiana/Marengo",
|
||||
"America/Indiana/Petersburg",
|
||||
"America/Indiana/Tell_City",
|
||||
"America/Indiana/Vevay",
|
||||
"America/Indiana/Vincennes",
|
||||
"America/Indiana/Winamac",
|
||||
"America/Indianapolis",
|
||||
"America/Inuvik",
|
||||
"America/Iqaluit",
|
||||
"America/Jamaica",
|
||||
"America/Jujuy",
|
||||
"America/Juneau",
|
||||
"America/Kentucky/Monticello",
|
||||
"America/Kralendijk",
|
||||
"America/La_Paz",
|
||||
"America/Lima",
|
||||
"America/Los_Angeles",
|
||||
"America/Louisville",
|
||||
"America/Lower_Princes",
|
||||
"America/Maceio",
|
||||
"America/Managua",
|
||||
"America/Manaus",
|
||||
"America/Marigot",
|
||||
"America/Martinique",
|
||||
"America/Matamoros",
|
||||
"America/Mazatlan",
|
||||
"America/Mendoza",
|
||||
"America/Menominee",
|
||||
"America/Merida",
|
||||
"America/Metlakatla",
|
||||
"America/Mexico_City",
|
||||
"America/Miquelon",
|
||||
"America/Moncton",
|
||||
"America/Monterrey",
|
||||
"America/Montevideo",
|
||||
"America/Montserrat",
|
||||
"America/Nassau",
|
||||
"America/New_York",
|
||||
"America/Nome",
|
||||
"America/Noronha",
|
||||
"America/North_Dakota/Beulah",
|
||||
"America/North_Dakota/Center",
|
||||
"America/North_Dakota/New_Salem",
|
||||
"America/Ojinaga",
|
||||
"America/Panama",
|
||||
"America/Paramaribo",
|
||||
"America/Phoenix",
|
||||
"America/Port-au-Prince",
|
||||
"America/Port_of_Spain",
|
||||
"America/Porto_Velho",
|
||||
"America/Puerto_Rico",
|
||||
"America/Punta_Arenas",
|
||||
"America/Rankin_Inlet",
|
||||
"America/Recife",
|
||||
"America/Regina",
|
||||
"America/Resolute",
|
||||
"America/Rio_Branco",
|
||||
"America/Santarem",
|
||||
"America/Santiago",
|
||||
"America/Santo_Domingo",
|
||||
"America/Sao_Paulo",
|
||||
"America/Scoresbysund",
|
||||
"America/Sitka",
|
||||
"America/St_Barthelemy",
|
||||
"America/St_Johns",
|
||||
"America/St_Kitts",
|
||||
"America/St_Lucia",
|
||||
"America/St_Thomas",
|
||||
"America/St_Vincent",
|
||||
"America/Swift_Current",
|
||||
"America/Tegucigalpa",
|
||||
"America/Thule",
|
||||
"America/Tijuana",
|
||||
"America/Toronto",
|
||||
"America/Tortola",
|
||||
"America/Vancouver",
|
||||
"America/Whitehorse",
|
||||
"America/Winnipeg",
|
||||
"America/Yakutat",
|
||||
"Antarctica/Casey",
|
||||
"Antarctica/Davis",
|
||||
"Antarctica/DumontDUrville",
|
||||
"Antarctica/Macquarie",
|
||||
"Antarctica/Mawson",
|
||||
"Antarctica/McMurdo",
|
||||
"Antarctica/Palmer",
|
||||
"Antarctica/Rothera",
|
||||
"Antarctica/Syowa",
|
||||
"Antarctica/Troll",
|
||||
"Antarctica/Vostok",
|
||||
"Arctic/Longyearbyen",
|
||||
"Asia/Aden",
|
||||
"Asia/Almaty",
|
||||
"Asia/Amman",
|
||||
"Asia/Anadyr",
|
||||
"Asia/Aqtau",
|
||||
"Asia/Aqtobe",
|
||||
"Asia/Ashgabat",
|
||||
"Asia/Atyrau",
|
||||
"Asia/Baghdad",
|
||||
"Asia/Bahrain",
|
||||
"Asia/Baku",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Barnaul",
|
||||
"Asia/Beirut",
|
||||
"Asia/Bishkek",
|
||||
"Asia/Brunei",
|
||||
"Asia/Chita",
|
||||
"Asia/Colombo",
|
||||
"Asia/Damascus",
|
||||
"Asia/Dhaka",
|
||||
"Asia/Dili",
|
||||
"Asia/Dubai",
|
||||
"Asia/Dushanbe",
|
||||
"Asia/Famagusta",
|
||||
"Asia/Gaza",
|
||||
"Asia/Hebron",
|
||||
"Asia/Hong_Kong",
|
||||
"Asia/Hovd",
|
||||
"Asia/Irkutsk",
|
||||
"Asia/Jakarta",
|
||||
"Asia/Jayapura",
|
||||
"Asia/Jerusalem",
|
||||
"Asia/Kabul",
|
||||
"Asia/Kamchatka",
|
||||
"Asia/Karachi",
|
||||
"Asia/Katmandu",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Khandyga",
|
||||
"Asia/Krasnoyarsk",
|
||||
"Asia/Kuala_Lumpur",
|
||||
"Asia/Kuching",
|
||||
"Asia/Kuwait",
|
||||
"Asia/Macau",
|
||||
"Asia/Magadan",
|
||||
"Asia/Makassar",
|
||||
"Asia/Manila",
|
||||
"Asia/Muscat",
|
||||
"Asia/Nicosia",
|
||||
"Asia/Novokuznetsk",
|
||||
"Asia/Novosibirsk",
|
||||
"Asia/Omsk",
|
||||
"Asia/Oral",
|
||||
"Asia/Phnom_Penh",
|
||||
"Asia/Pontianak",
|
||||
"Asia/Pyongyang",
|
||||
"Asia/Qatar",
|
||||
"Asia/Qostanay",
|
||||
"Asia/Qyzylorda",
|
||||
"Asia/Rangoon",
|
||||
"Asia/Riyadh",
|
||||
"Asia/Saigon",
|
||||
"Asia/Sakhalin",
|
||||
"Asia/Samarkand",
|
||||
"Asia/Seoul",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Singapore",
|
||||
"Asia/Srednekolymsk",
|
||||
"Asia/Taipei",
|
||||
"Asia/Tashkent",
|
||||
"Asia/Tbilisi",
|
||||
"Asia/Tehran",
|
||||
"Asia/Thimphu",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Tomsk",
|
||||
"Asia/Ulaanbaatar",
|
||||
"Asia/Urumqi",
|
||||
"Asia/Ust-Nera",
|
||||
"Asia/Vientiane",
|
||||
"Asia/Vladivostok",
|
||||
"Asia/Yakutsk",
|
||||
"Asia/Yekaterinburg",
|
||||
"Asia/Yerevan",
|
||||
"Atlantic/Azores",
|
||||
"Atlantic/Bermuda",
|
||||
"Atlantic/Canary",
|
||||
"Atlantic/Cape_Verde",
|
||||
"Atlantic/Faeroe",
|
||||
"Atlantic/Madeira",
|
||||
"Atlantic/Reykjavik",
|
||||
"Atlantic/South_Georgia",
|
||||
"Atlantic/St_Helena",
|
||||
"Atlantic/Stanley",
|
||||
"Australia/Adelaide",
|
||||
"Australia/Brisbane",
|
||||
"Australia/Broken_Hill",
|
||||
"Australia/Darwin",
|
||||
"Australia/Eucla",
|
||||
"Australia/Hobart",
|
||||
"Australia/Lindeman",
|
||||
"Australia/Lord_Howe",
|
||||
"Australia/Melbourne",
|
||||
"Australia/Perth",
|
||||
"Australia/Sydney",
|
||||
"Europe/Amsterdam",
|
||||
"Europe/Andorra",
|
||||
"Europe/Astrakhan",
|
||||
"Europe/Athens",
|
||||
"Europe/Belgrade",
|
||||
"Europe/Berlin",
|
||||
"Europe/Bratislava",
|
||||
"Europe/Brussels",
|
||||
"Europe/Bucharest",
|
||||
"Europe/Budapest",
|
||||
"Europe/Busingen",
|
||||
"Europe/Chisinau",
|
||||
"Europe/Copenhagen",
|
||||
"Europe/Dublin",
|
||||
"Europe/Gibraltar",
|
||||
"Europe/Guernsey",
|
||||
"Europe/Helsinki",
|
||||
"Europe/Isle_of_Man",
|
||||
"Europe/Istanbul",
|
||||
"Europe/Jersey",
|
||||
"Europe/Kaliningrad",
|
||||
"Europe/Kiev",
|
||||
"Europe/Kirov",
|
||||
"Europe/Lisbon",
|
||||
"Europe/Ljubljana",
|
||||
"Europe/London",
|
||||
"Europe/Luxembourg",
|
||||
"Europe/Madrid",
|
||||
"Europe/Malta",
|
||||
"Europe/Mariehamn",
|
||||
"Europe/Minsk",
|
||||
"Europe/Monaco",
|
||||
"Europe/Moscow",
|
||||
"Europe/Oslo",
|
||||
"Europe/Paris",
|
||||
"Europe/Podgorica",
|
||||
"Europe/Prague",
|
||||
"Europe/Riga",
|
||||
"Europe/Rome",
|
||||
"Europe/Samara",
|
||||
"Europe/San_Marino",
|
||||
"Europe/Sarajevo",
|
||||
"Europe/Saratov",
|
||||
"Europe/Simferopol",
|
||||
"Europe/Skopje",
|
||||
"Europe/Sofia",
|
||||
"Europe/Stockholm",
|
||||
"Europe/Tallinn",
|
||||
"Europe/Tirane",
|
||||
"Europe/Ulyanovsk",
|
||||
"Europe/Vaduz",
|
||||
"Europe/Vatican",
|
||||
"Europe/Vienna",
|
||||
"Europe/Vilnius",
|
||||
"Europe/Volgograd",
|
||||
"Europe/Warsaw",
|
||||
"Europe/Zagreb",
|
||||
"Europe/Zurich",
|
||||
"Indian/Antananarivo",
|
||||
"Indian/Chagos",
|
||||
"Indian/Christmas",
|
||||
"Indian/Cocos",
|
||||
"Indian/Comoro",
|
||||
"Indian/Kerguelen",
|
||||
"Indian/Mahe",
|
||||
"Indian/Maldives",
|
||||
"Indian/Mauritius",
|
||||
"Indian/Mayotte",
|
||||
"Indian/Reunion",
|
||||
"Pacific/Apia",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Bougainville",
|
||||
"Pacific/Chatham",
|
||||
"Pacific/Easter",
|
||||
"Pacific/Efate",
|
||||
"Pacific/Enderbury",
|
||||
"Pacific/Fakaofo",
|
||||
"Pacific/Fiji",
|
||||
"Pacific/Funafuti",
|
||||
"Pacific/Galapagos",
|
||||
"Pacific/Gambier",
|
||||
"Pacific/Guadalcanal",
|
||||
"Pacific/Guam",
|
||||
"Pacific/Honolulu",
|
||||
"Pacific/Kiritimati",
|
||||
"Pacific/Kosrae",
|
||||
"Pacific/Kwajalein",
|
||||
"Pacific/Majuro",
|
||||
"Pacific/Marquesas",
|
||||
"Pacific/Midway",
|
||||
"Pacific/Nauru",
|
||||
"Pacific/Niue",
|
||||
"Pacific/Norfolk",
|
||||
"Pacific/Noumea",
|
||||
"Pacific/Pago_Pago",
|
||||
"Pacific/Palau",
|
||||
"Pacific/Pitcairn",
|
||||
"Pacific/Ponape",
|
||||
"Pacific/Port_Moresby",
|
||||
"Pacific/Rarotonga",
|
||||
"Pacific/Saipan",
|
||||
"Pacific/Tahiti",
|
||||
"Pacific/Tarawa",
|
||||
"Pacific/Tongatapu",
|
||||
"Pacific/Truk",
|
||||
"Pacific/Wake",
|
||||
"Pacific/Wallis",
|
||||
] as const;
|
||||
|
||||
export type Timezone = (typeof TIMEZONES)[number];
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* IMPORTANT: If you update this timezone array, please also update the TIMEZONES array
|
||||
* in companion/constants/timezones.ts to keep the companion apps in sync.
|
||||
*/
|
||||
export const IntlSupportedTimeZones = [
|
||||
"Africa/Abidjan",
|
||||
"Africa/Accra",
|
||||
|
||||
Reference in New Issue
Block a user