feat(companion): component improvements, migrate to path aliases, expo-haptics, expo-image (#26226)

* react compiler

* remove compilation mode 'all', it will use infer by default

* feat(companion): add theme tokens, path aliases, and component improvements

- Extend tailwind.config.js with Cal.com brand color tokens (cal.text, cal.bg, cal.border, cal.accent, cal.brand)
- Create theme/colors.ts for JS usage where Tailwind classes can't be used
- Configure path aliases in tsconfig.json (@/components, @/hooks, @/utils, etc.)
- Add expo-image and expo-haptics dependencies
- Enhance AppPressable with Reanimated animations (opacity + scale) and haptic feedback
- Extract shared logic from BookingListItem into useBookingListItemData hook and BookingListItemParts
- Extract shared logic from EventTypeListItem into useEventTypeListItemData hook and EventTypeListItemParts
- Extract shared logic from AvailabilityListItem into AvailabilityListItemParts
- Update all list item components to use new theme tokens instead of hardcoded hex values

* fix react compiler non memo issue

* feat(companion): migrate to path aliases and expo-image

- Remove theme/colors.ts and theme/index.ts (defer dark mode to future PR)
- Replace colors import with hardcoded hex values in components
- Migrate all files to use path aliases (@/components/*, @/hooks/*, etc.)
- Migrate SvgImage to use expo-image for better performance
- Update tsconfig.json to remove @/theme/* alias

* fix(companion): add path aliases to extension tsconfig

Add baseUrl and paths configuration to tsconfig.extension.json to support
path aliases in shared types used by the extension build.
This commit is contained in:
Dhairyashil Shinde authored and GitHub committed 2025-12-28 08:08:33 -03:00
1 parent b6b0492362
commit 9203cb262c
81 files changed
+1002 -671

No files matched your search

@@ -2,7 +2,7 @@ import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack } from "expo-router";
import { useState } from "react";
import { Platform } from "react-native";
import { AvailabilityListScreen } from "../../../components/screens/AvailabilityListScreen";
import { AvailabilityListScreen } from "@/components/screens/AvailabilityListScreen";
export default function Availability() {
const [searchQuery, setSearchQuery] = useState("");
@@ -1,10 +1,10 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, Text, View } from "react-native";
import { AppPressable } from "../../../components/AppPressable";
import type { AddGuestsScreenHandle } from "../../../components/screens/AddGuestsScreen";
import AddGuestsScreenComponent from "../../../components/screens/AddGuestsScreen";
import { type Booking, CalComAPIService } from "../../../services/calcom";
import { AppPressable } from "@/components/AppPressable";
import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen";
import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
export default function AddGuests() {
const { uid } = useLocalSearchParams<{ uid: string }>();
@@ -1,10 +1,10 @@
import { Stack, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert } from "react-native";
import { BookingDetailScreen } from "../../../components/screens/BookingDetailScreen";
import { useAuth } from "../../../contexts/AuthContext";
import { type Booking, CalComAPIService } from "../../../services/calcom";
import { type BookingActionsResult, getBookingActions } from "../../../utils/booking-actions";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import { useAuth } from "@/contexts/AuthContext";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
// Empty actions result for when no booking is loaded
const EMPTY_ACTIONS: BookingActionsResult = {
@@ -1,10 +1,10 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, Text, View } from "react-native";
import { AppPressable } from "../../../components/AppPressable";
import type { EditLocationScreenHandle } from "../../../components/screens/EditLocationScreen";
import EditLocationScreenComponent from "../../../components/screens/EditLocationScreen";
import { type Booking, CalComAPIService } from "../../../services/calcom";
import { AppPressable } from "@/components/AppPressable";
import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen";
import EditLocationScreenComponent from "@/components/screens/EditLocationScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
export default function EditLocation() {
const { uid } = useLocalSearchParams<{ uid: string }>();
@@ -3,9 +3,9 @@ import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack } from "expo-router";
import { useState } from "react";
import { BookingListScreen } from "../../../components/booking-list-screen/BookingListScreen";
import { useEventTypes } from "../../../hooks";
import { useActiveBookingFilter } from "../../../hooks/useActiveBookingFilter";
import { BookingListScreen } from "@/components/booking-list-screen/BookingListScreen";
import { useEventTypes } from "@/hooks";
import { useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
export default function Bookings() {
const [searchQuery, setSearchQuery] = useState("");
+5 -5
View File
@@ -3,11 +3,11 @@ import SegmentedControl from "@react-native-segmented-control/segmented-control"
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { useState } from "react";
import { Text, TextInput, TouchableOpacity, View } from "react-native";
import { BookingListScreen } from "../../../components/booking-list-screen/BookingListScreen";
import { Header } from "../../../components/Header";
import { useActiveBookingFilter } from "../../../hooks/useActiveBookingFilter";
import type { EventType } from "../../../services/calcom";
import { CalComAPIService } from "../../../services/calcom";
import { BookingListScreen } from "@/components/booking-list-screen/BookingListScreen";
import { Header } from "@/components/Header";
import { useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
import type { EventType } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
export default function Bookings() {
const [searchQuery, setSearchQuery] = useState("");
@@ -1,8 +1,8 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import MarkNoShowScreenComponent from "../../../components/screens/MarkNoShowScreen";
import { type Booking, CalComAPIService } from "../../../services/calcom";
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
interface Attendee {
id?: number | string;
@@ -1,9 +1,9 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import MeetingSessionDetailsScreenComponent from "../../../components/screens/MeetingSessionDetailsScreen";
import { CalComAPIService } from "../../../services/calcom";
import type { ConferencingSession } from "../../../services/types/bookings.types";
import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { ConferencingSession } from "@/services/types/bookings.types";
export default function MeetingSessionDetails() {
const { uid } = useLocalSearchParams<{ uid: string }>();
@@ -1,10 +1,10 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, Text, View } from "react-native";
import { AppPressable } from "../../../components/AppPressable";
import type { RescheduleScreenHandle } from "../../../components/screens/RescheduleScreen";
import RescheduleScreenComponent from "../../../components/screens/RescheduleScreen";
import { type Booking, CalComAPIService } from "../../../services/calcom";
import { AppPressable } from "@/components/AppPressable";
import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen";
import RescheduleScreenComponent from "@/components/screens/RescheduleScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
export default function Reschedule() {
const { uid } = useLocalSearchParams<{ uid: string }>();
@@ -1,9 +1,9 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, Alert, Platform, View } from "react-native";
import ViewRecordingsScreenComponent from "../../../components/screens/ViewRecordingsScreen";
import { CalComAPIService } from "../../../services/calcom";
import type { BookingRecording } from "../../../services/types/bookings.types";
import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { BookingRecording } from "@/services/types/bookings.types";
export default function ViewRecordings() {
const { uid } = useLocalSearchParams<{ uid: string }>();
+14 -14
View File
@@ -15,26 +15,26 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { EmptyScreen } from "../../../components/EmptyScreen";
import { EventTypeListItem } from "../../../components/event-type-list-item/EventTypeListItem";
import { FullScreenModal } from "../../../components/FullScreenModal";
import { Header } from "../../../components/Header";
import { LoadingSpinner } from "../../../components/LoadingSpinner";
import { EmptyScreen } from "@/components/EmptyScreen";
import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem";
import { FullScreenModal } from "@/components/FullScreenModal";
import { Header } from "@/components/Header";
import { LoadingSpinner } from "@/components/LoadingSpinner";
import {
useCreateEventType,
useDeleteEventType,
useDuplicateEventType,
useEventTypes,
useUsername,
} from "../../../hooks";
import { CalComAPIService, type EventType } from "../../../services/calcom";
import { showErrorAlert } from "../../../utils/alerts";
import { openInAppBrowser } from "../../../utils/browser";
import { getEventDuration } from "../../../utils/getEventDuration";
import { offlineAwareRefresh } from "../../../utils/network";
import { normalizeMarkdown } from "../../../utils/normalizeMarkdown";
import { shadows } from "../../../utils/shadows";
import { slugify } from "../../../utils/slugify";
} from "@/hooks";
import { CalComAPIService, type EventType } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getEventDuration } from "@/utils/getEventDuration";
import { offlineAwareRefresh } from "@/utils/network";
import { normalizeMarkdown } from "@/utils/normalizeMarkdown";
import { shadows } from "@/utils/shadows";
import { slugify } from "@/utils/slugify";
export default function EventTypes() {
const router = useRouter();
+5 -5
View File
@@ -1,11 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { Alert, Platform, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { Header } from "../../components/Header";
import { LogoutConfirmModal } from "../../components/LogoutConfirmModal";
import { useAuth } from "../../contexts/AuthContext";
import { showErrorAlert } from "../../utils/alerts";
import { openInAppBrowser } from "../../utils/browser";
import { Header } from "@/components/Header";
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
import { useAuth } from "@/contexts/AuthContext";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
interface MoreMenuItem {
name: string;
+4 -4
View File
@@ -1,9 +1,9 @@
import { Stack } from "expo-router";
import { Platform, StatusBar, View } from "react-native";
import LoginScreenComponent from "../components/LoginScreen";
import { NetworkStatusBanner } from "../components/NetworkStatusBanner";
import { AuthProvider, useAuth } from "../contexts/AuthContext";
import { QueryProvider } from "../contexts/QueryContext";
import LoginScreenComponent from "@/components/LoginScreen";
import { NetworkStatusBanner } from "@/components/NetworkStatusBanner";
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import { QueryProvider } from "@/contexts/QueryContext";
import "../global.css";
function RootLayoutContent() {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Stack, useLocalSearchParams } from "expo-router";
import { AvailabilityDetailScreen } from "../components/screens/AvailabilityDetailScreen";
import { AvailabilityDetailScreen } from "@/components/screens/AvailabilityDetailScreen";
export default function AvailabilityDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
+12 -12
View File
@@ -15,28 +15,28 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AdvancedTab } from "../components/event-type-detail/tabs/AdvancedTab";
import { AvailabilityTab } from "../components/event-type-detail/tabs/AvailabilityTab";
import { BasicsTab } from "../components/event-type-detail/tabs/BasicsTab";
import { LimitsTab } from "../components/event-type-detail/tabs/LimitsTab";
import { RecurringTab } from "../components/event-type-detail/tabs/RecurringTab";
import { truncateTitle } from "../components/event-type-detail/utils";
import { buildPartialUpdatePayload } from "../components/event-type-detail/utils/buildPartialUpdatePayload";
import { AdvancedTab } from "@/components/event-type-detail/tabs/AdvancedTab";
import { AvailabilityTab } from "@/components/event-type-detail/tabs/AvailabilityTab";
import { BasicsTab } from "@/components/event-type-detail/tabs/BasicsTab";
import { LimitsTab } from "@/components/event-type-detail/tabs/LimitsTab";
import { RecurringTab } from "@/components/event-type-detail/tabs/RecurringTab";
import { truncateTitle } from "@/components/event-type-detail/utils";
import { buildPartialUpdatePayload } from "@/components/event-type-detail/utils/buildPartialUpdatePayload";
import {
CalComAPIService,
type ConferencingOption,
type EventType,
type Schedule,
} from "../services/calcom";
import type { LocationItem, LocationOptionGroup } from "../types/locations";
import { showErrorAlert } from "../utils/alerts";
import { openInAppBrowser } from "../utils/browser";
} from "@/services/calcom";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import {
buildLocationOptions,
mapApiLocationToItem,
mapItemToApiLocation,
validateLocationItem,
} from "../utils/locationHelpers";
} from "@/utils/locationHelpers";
// Type definitions for extended EventType fields not in the base type
interface EventTypeExtended {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native";
import { useAuth } from "../../contexts";
import { useAuth } from "@/contexts";
export default function OAuthCallback() {
const router = useRouter();
+6
View File
@@ -20,6 +20,8 @@
"expo-dev-client": "6.1.0-canary-20251216-3f01dbf",
"expo-device": "8.0.11-canary-20251216-3f01dbf",
"expo-glass-effect": "0.2.0-canary-20251216-3f01dbf",
"expo-haptics": "^15.0.8",
"expo-image": "^3.0.11",
"expo-linking": "8.0.11-canary-20251216-3f01dbf",
"expo-router": "7.0.0-canary-20251216-3f01dbf",
"expo-secure-store": "15.0.9-canary-20251216-3f01dbf",
@@ -1051,6 +1053,10 @@
"expo-glass-effect": ["expo-glass-effect@0.2.0-canary-20251216-3f01dbf", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf", "react": "*", "react-native": "*" } }, "sha512-n1L+Y8Gr0ZjuIu+NYZuoggBaSi7djXLbVfGxjebbe6OJczHCni06vnmDo2Zz/N2g7vqzBxZZeV/5OXo2v3YBYA=="],
"expo-haptics": ["expo-haptics@15.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g=="],
"expo-image": ["expo-image@3.0.11", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA=="],
"expo-json-utils": ["expo-json-utils@0.15.1-canary-20251216-3f01dbf", "", {}, "sha512-4pU8mIFJSXZq0jOdTEUz68SKZCKYreISzqRyFVQ5AflWyGDmZhVFgAN5jbwuzNTMhcG7+mRnWkkrOemr1aSpbg=="],
"expo-keep-awake": ["expo-keep-awake@15.0.9-canary-20251216-3f01dbf", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf", "react": "*" } }, "sha512-2BmEozgENR4fN54R39qncv+td928OJaaKz82FsKLA7hgncwM/BqPBydLUnWe3q9f+yj5iDcJlstoHC9J1D9T7w=="],
+100 -10
View File
@@ -1,16 +1,106 @@
import React from "react";
import type { TouchableOpacityProps } from "react-native";
import { TouchableOpacity } from "react-native";
import * as Haptics from "expo-haptics";
import React, { useCallback } from "react";
import type { GestureResponderEvent, PressableProps, ViewStyle } from "react-native";
import { Pressable } from "react-native";
import Animated, {
type SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
type AppPressableProps = TouchableOpacityProps & {
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
type AppPressableProps = PressableProps & {
className?: string;
style?: ViewStyle;
enableHaptics?: boolean;
hapticStyle?: Haptics.ImpactFeedbackStyle;
/** @deprecated Use the built-in animation instead. Kept for backward compatibility. */
activeOpacity?: number;
};
export const AppPressable = React.forwardRef<
React.ElementRef<typeof TouchableOpacity>,
AppPressableProps
>(({ className, ...props }, ref) => {
return <TouchableOpacity ref={ref} className={className} {...props} />;
});
function usePressAnimations(activeOpacity: number) {
const opacity = useSharedValue(1);
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
const animatePressIn = useCallback(
(op: SharedValue<number>, sc: SharedValue<number>) => {
op.value = withTiming(activeOpacity, { duration: 100 });
sc.value = withTiming(0.98, { duration: 100 });
},
[activeOpacity]
);
const animatePressOut = useCallback((op: SharedValue<number>, sc: SharedValue<number>) => {
op.value = withTiming(1, { duration: 100 });
sc.value = withTiming(1, { duration: 100 });
}, []);
return { animatedStyle, animatePressIn, animatePressOut, opacity, scale };
}
export const AppPressable = React.forwardRef<React.ElementRef<typeof Pressable>, AppPressableProps>(
(
{
className,
style,
enableHaptics = true,
hapticStyle = Haptics.ImpactFeedbackStyle.Light,
activeOpacity = 0.7,
onPressIn,
onPressOut,
onPress,
...props
},
ref
) => {
const { animatedStyle, animatePressIn, animatePressOut, opacity, scale } =
usePressAnimations(activeOpacity);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
animatePressIn(opacity, scale);
onPressIn?.(event);
},
[animatePressIn, onPressIn, opacity, scale]
);
const handlePressOut = useCallback(
(event: GestureResponderEvent) => {
animatePressOut(opacity, scale);
onPressOut?.(event);
},
[animatePressOut, onPressOut, opacity, scale]
);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
if (enableHaptics) {
Haptics.impactAsync(hapticStyle);
}
onPress?.(event);
},
[enableHaptics, hapticStyle, onPress]
);
return (
<AnimatedPressable
ref={ref}
className={className}
style={[style, animatedStyle]}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handlePress}
{...props}
/>
);
}
);
AppPressable.displayName = "AppPressable";
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import { CalComAPIService } from "../services/calcom";
import { useAuth } from "@/contexts/AuthContext";
import { CalComAPIService } from "@/services/calcom";
export function AuthTokenProvider({ children }: { children: React.ReactNode }) {
const { accessToken, isAuthenticated, isWebSession: _isWebSession } = useAuth();
@@ -8,8 +8,8 @@
import { Ionicons } from "@expo/vector-icons";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Booking } from "../services/calcom";
import type { BookingActionsResult } from "../utils/booking-actions";
import type { Booking } from "@/services/calcom";
import type { BookingActionsResult } from "@/utils/booking-actions";
import { FullScreenModal } from "./FullScreenModal";
export interface BookingActionsModalProps {
+2 -2
View File
@@ -12,8 +12,8 @@
import { Ionicons } from "@expo/vector-icons";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
import type { Booking } from "../services/calcom";
import type { BookingActionsResult } from "../utils/booking-actions";
import type { Booking } from "@/services/calcom";
import type { BookingActionsResult } from "@/utils/booking-actions";
import { FullScreenModal } from "./FullScreenModal";
export interface BookingActionsModalProps {
@@ -11,7 +11,7 @@
import { Ionicons } from "@expo/vector-icons";
import { StyleSheet, Text, View } from "react-native";
import { useQueryContext } from "../contexts/QueryContext";
import { useQueryContext } from "@/contexts/QueryContext";
/**
* Props for the CacheStatusIndicator component
+3 -3
View File
@@ -12,9 +12,9 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, type UserProfile } from "../services/calcom";
import { openInAppBrowser } from "../utils/browser";
import { getAvatarUrl } from "../utils/getAvatarUrl";
import { CalComAPIService, type UserProfile } from "@/services/calcom";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { CalComLogo } from "./CalComLogo";
import { FullScreenModal } from "./FullScreenModal";
+2 -2
View File
@@ -16,13 +16,13 @@ import {
TouchableOpacity,
View,
} from "react-native";
import type { LocationItem, LocationOptionGroup } from "../types/locations";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
import {
createLocationItemFromOption,
getLocationInputLabel,
getLocationInputPlaceholder,
locationRequiresInput,
} from "../utils/locationHelpers";
} from "@/utils/locationHelpers";
import { SvgImage } from "./SvgImage";
interface LocationsListProps {
+3 -3
View File
@@ -1,8 +1,8 @@
import { Platform, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAuth } from "../contexts/AuthContext";
import { showErrorAlert } from "../utils/alerts";
import { openInAppBrowser } from "../utils/browser";
import { useAuth } from "@/contexts/AuthContext";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { CalComLogo } from "./CalComLogo";
export function LoginScreen() {
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Alert, Platform, Text, TouchableOpacity } from "react-native";
import { useAuth } from "../contexts/AuthContext";
import { useAuth } from "@/contexts/AuthContext";
import { LogoutConfirmModal } from "./LogoutConfirmModal";
interface LogoutButtonProps {
+5 -3
View File
@@ -1,6 +1,7 @@
import { Image } from "expo-image";
import type React from "react";
import { useState } from "react";
import { Image, View } from "react-native";
import { View } from "react-native";
import { SvgUri } from "react-native-svg";
interface SvgImageProps {
@@ -22,13 +23,14 @@ export const SvgImage: React.FC<SvgImageProps> = ({ uri, width, height, style })
const isSvg = pathname.toLowerCase().endsWith(".svg");
if (!isSvg || useFallback) {
// Use regular Image for non-SVG or if SVG failed
// Use expo-image for non-SVG or if SVG failed (better performance with caching)
return (
<View style={style}>
<Image
source={{ uri }}
style={{ width, height }}
resizeMode="contain"
contentFit="contain"
transition={200}
onError={() => {
// Image load errors are expected for invalid/missing URLs - silently handled
}}
@@ -1,10 +1,10 @@
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 { Pressable, Text, View } from "react-native";
import { Pressable, View } from "react-native";
import type { SFSymbols7_0 } from "sf-symbols-typescript";
import type { AvailabilityListItemProps } from "./AvailabilityListItem";
import { AvailabilitySlots, ScheduleName, TimeZoneRow } from "./AvailabilityListItemParts";
export const AvailabilityListItem = ({
item: schedule,
@@ -45,7 +45,7 @@ export const AvailabilityListItem = ({
return (
<View
className="border-b border-[#E5E5EA] bg-white"
className="border-b border-cal-border bg-cal-bg"
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center">
@@ -55,42 +55,15 @@ export const AvailabilityListItem = ({
accessibilityRole="button"
>
<View>
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-semibold text-[#333]">{schedule.name}</Text>
{schedule.isDefault && (
<View className="ml-2 rounded bg-[#666] px-2 py-0.5">
<Text className="text-xs font-semibold text-white">Default</Text>
</View>
)}
</View>
{schedule.availability && schedule.availability.length > 0 ? (
<View>
{schedule.availability.map((slot, slotIndex) => (
<View
key={`${schedule.id}-${slot.days.join("-")}-${slotIndex}`}
className={slotIndex > 0 ? "mt-2" : ""}
>
<Text className="text-sm text-[#666]">
{slot.days.join(", ")} {slot.startTime} - {slot.endTime}
</Text>
</View>
))}
</View>
) : (
<Text className="text-sm text-[#666]">No availability set</Text>
)}
<View className="mt-2 flex-row items-center">
<Ionicons name="globe-outline" size={14} color="#666" />
<Text className="ml-1.5 text-sm text-[#666]">{schedule.timeZone}</Text>
</View>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
</Pressable>
{/* Three dots menu - fixed hit target so it doesn't get squeezed off-screen */}
<View
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
>
<Host matchContents>
@@ -1,6 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import type { Schedule } from "../../hooks";
import { TouchableOpacity, View } from "react-native";
import type { Schedule } from "@/hooks";
import {
AvailabilitySlots,
ScheduleActionsButton,
ScheduleName,
TimeZoneRow,
} from "./AvailabilityListItemParts";
export interface AvailabilityListItemProps {
item: Schedule;
@@ -23,57 +28,22 @@ export const AvailabilityListItem = ({
}: AvailabilityListItemProps) => {
return (
<TouchableOpacity
className="border-b border-[#E5E5EA] bg-white active:bg-[#F8F9FA] "
className="border-b border-cal-border bg-cal-bg active:bg-cal-bg-secondary"
onPress={() => handleSchedulePress(schedule)}
onLongPress={() => handleScheduleLongPress(schedule)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<View className="mr-4 flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-semibold text-[#333]">{schedule.name}</Text>
{schedule.isDefault && (
<View className="ml-2 rounded bg-[#666] px-2 py-0.5">
<Text className="text-xs font-semibold text-white">Default</Text>
</View>
)}
</View>
{schedule.availability && schedule.availability.length > 0 ? (
<View>
{schedule.availability.map((slot, slotIndex) => (
<View
key={`${schedule.id}-${slot.days.join("-")}-${slotIndex}`}
className={slotIndex > 0 ? "mt-2" : ""}
>
<Text className="text-sm text-[#666]">
{slot.days.join(", ")} {slot.startTime} - {slot.endTime}
</Text>
</View>
))}
</View>
) : (
<Text className="text-sm text-[#666]">No availability set</Text>
)}
<View className="mt-2 flex-row items-center">
<Ionicons name="globe-outline" size={14} color="#666" />
<Text className="ml-1.5 text-sm text-[#666]">{schedule.timeZone}</Text>
</View>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
{/* Three dots button - vertically centered on the right */}
<TouchableOpacity
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={(e) => {
e.stopPropagation();
setSelectedSchedule(schedule);
setShowActionsModal(true);
}}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
<ScheduleActionsButton
schedule={schedule}
setSelectedSchedule={setSelectedSchedule}
setShowActionsModal={setShowActionsModal}
/>
</View>
</TouchableOpacity>
);
@@ -0,0 +1,86 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import type { Schedule } from "@/hooks";
interface ScheduleNameProps {
name: string;
isDefault?: boolean;
}
export function ScheduleName({ name, isDefault }: ScheduleNameProps) {
return (
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-semibold text-cal-text">{name}</Text>
{isDefault && (
<View className="ml-2 rounded bg-cal-text-secondary px-2 py-0.5">
<Text className="text-xs font-semibold text-white">Default</Text>
</View>
)}
</View>
);
}
interface AvailabilitySlotsProps {
availability?: Schedule["availability"];
scheduleId: number;
}
export function AvailabilitySlots({ availability, scheduleId }: AvailabilitySlotsProps) {
if (!availability || availability.length === 0) {
return <Text className="text-sm text-cal-text-secondary">No availability set</Text>;
}
return (
<View>
{availability.map((slot, slotIndex) => (
<View
key={`${scheduleId}-${slot.days.join("-")}-${slotIndex}`}
className={slotIndex > 0 ? "mt-2" : ""}
>
<Text className="text-sm text-cal-text-secondary">
{slot.days.join(", ")} {slot.startTime} - {slot.endTime}
</Text>
</View>
))}
</View>
);
}
interface TimeZoneRowProps {
timeZone: string;
}
export function TimeZoneRow({ timeZone }: TimeZoneRowProps) {
return (
<View className="mt-2 flex-row items-center">
<Ionicons name="globe-outline" size={14} color="#666666" />
<Text className="ml-1.5 text-sm text-cal-text-secondary">{timeZone}</Text>
</View>
);
}
interface ScheduleActionsButtonProps {
schedule: Schedule;
setSelectedSchedule: (schedule: Schedule) => void;
setShowActionsModal: (show: boolean) => void;
}
export function ScheduleActionsButton({
schedule,
setSelectedSchedule,
setShowActionsModal,
}: ScheduleActionsButtonProps) {
return (
<TouchableOpacity
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
onPress={(e) => {
e.stopPropagation();
setSelectedSchedule(schedule);
setShowActionsModal(true);
}}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
);
}
@@ -1,16 +1,21 @@
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 React from "react";
import { Linking, Pressable, Text, TouchableOpacity, View } from "react-native";
import { Pressable, View } from "react-native";
import type { SFSymbols7_0 } from "sf-symbols-typescript";
import { showErrorAlert } from "../../utils/alerts";
import { getBookingActions } from "../../utils/booking-actions";
import { formatDate, formatTime, getHostAndAttendeesDisplay } from "../../utils/bookings-utils";
import { getMeetingInfo } from "../../utils/meetings-utils";
import { SvgImage } from "../SvgImage";
import { getBookingActions } from "@/utils/booking-actions";
import {
BadgesRow,
BookingDescription,
BookingTitle,
ConfirmRejectButtons,
HostAndAttendees,
MeetingLink,
TimeAndDateRow,
} from "./BookingListItemParts";
import type { BookingListItemProps } from "./types";
import { useBookingListItemData } from "./useBookingListItemData";
export const BookingListItem: React.FC<BookingListItemProps> = ({
booking,
@@ -31,19 +36,17 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
onReportBooking,
onCancelBooking,
}) => {
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isUpcoming = new Date(endTime) >= new Date();
const isPending = booking.status?.toUpperCase() === "PENDING";
const isCancelled = booking.status?.toUpperCase() === "CANCELLED";
const isRejected = booking.status?.toUpperCase() === "REJECTED";
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
const meetingInfo = getMeetingInfo(booking.location);
// Check if any attendee is marked as no-show
const hasNoShowAttendee = booking.attendees?.some(
(att: { noShow?: boolean; absent?: boolean }) => att.noShow === true || att.absent === true
);
const {
isUpcoming,
isPending,
isCancelled,
isRejected,
hostAndAttendeesDisplay,
meetingInfo,
hasNoShowAttendee,
formattedDate,
formattedTimeRange,
} = useBookingListItemData(booking, userEmail);
// Use centralized action gating for consistency
const actions = React.useMemo(() => {
@@ -135,123 +138,32 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
.map(({ label, icon, onPress, role }) => ({ label, icon, onPress, role }));
return (
<View className="border-b border-[#E5E5EA] bg-white">
<View className="border-b border-cal-border bg-cal-bg">
<Pressable
onPress={() => onPress(booking)}
onLongPress={() => onLongPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
className="active:bg-[#F8F9FA]"
className="active:bg-cal-bg-secondary"
>
{/* Time and Date Row */}
<View className="mb-2 flex-row flex-wrap items-center">
<Text className="text-sm font-medium text-[#333]">
{formatDate(startTime, isUpcoming)}
</Text>
<Text className="ml-2 text-sm text-[#666]">
{formatTime(startTime)} - {formatTime(endTime)}
</Text>
</View>
{/* Badges Row */}
<View className="mb-3 flex-row flex-wrap items-center">
{isPending ? (
<View className="mb-1 mr-2 rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
</View>
) : null}
</View>
{/* Title */}
<Text
className={`mb-2 text-lg font-medium leading-5 text-[#333] ${isCancelled || isRejected ? "line-through" : ""}`}
numberOfLines={2}
>
{booking.title}
</Text>
{/* Description */}
{booking.description ? (
<Text className="mb-2 text-sm leading-5 text-[#666]" numberOfLines={1}>
"{booking.description}"
</Text>
) : null}
{/* Host and Attendees */}
{hostAndAttendeesDisplay ? (
<View className="mb-2 flex-row items-center">
<Text className="text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
{hasNoShowAttendee && (
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-1.5 py-0.5">
<Ionicons name="eye-off" size={10} color="#DC2626" />
<Text className="ml-0.5 text-[10px] font-medium text-[#DC2626]">No-show</Text>
</View>
)}
</View>
) : null}
{/* Meeting Link */}
{meetingInfo ? (
<View className="mb-1 flex-row">
<TouchableOpacity
className="flex-row items-center"
style={{ alignSelf: "flex-start" }}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
onPress={async (e) => {
e.stopPropagation();
try {
await Linking.openURL(meetingInfo.cleanUrl);
} catch {
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
}
}}
>
{meetingInfo.iconUrl ? (
<SvgImage
uri={meetingInfo.iconUrl}
width={16}
height={16}
style={{ marginRight: 6 }}
/>
) : (
<Ionicons name="videocam" size={16} color="#007AFF" style={{ marginRight: 6 }} />
)}
<Text className="text-sm font-medium text-[#007AFF]">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
) : null}
<TimeAndDateRow formattedDate={formattedDate} formattedTimeRange={formattedTimeRange} />
<BadgesRow isPending={isPending} />
<BookingTitle title={booking.title} isCancelled={isCancelled} isRejected={isRejected} />
<BookingDescription description={booking.description} />
<HostAndAttendees
hostAndAttendeesDisplay={hostAndAttendeesDisplay}
hasNoShowAttendee={hasNoShowAttendee}
/>
<MeetingLink meetingInfo={meetingInfo} />
</Pressable>
<View className="flex-row items-center justify-end gap-2">
{isPending ? (
<>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onConfirm(booking);
}}
>
<Ionicons name="checkmark" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Confirm</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onReject(booking);
}}
>
<Ionicons name="close" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Reject</Text>
</TouchableOpacity>
</>
) : null}
<ConfirmRejectButtons
booking={booking}
isPending={isPending}
isConfirming={isConfirming}
isDeclining={isDeclining}
onConfirm={onConfirm}
onReject={onReject}
/>
{/* iOS Context Menu */}
<Host matchContents>
@@ -1,11 +1,17 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { Linking, Text, TouchableOpacity, View } from "react-native";
import { showErrorAlert } from "../../utils/alerts";
import { formatDate, formatTime, getHostAndAttendeesDisplay } from "../../utils/bookings-utils";
import { getMeetingInfo } from "../../utils/meetings-utils";
import { SvgImage } from "../SvgImage";
import { TouchableOpacity, View } from "react-native";
import {
BadgesRow,
BookingDescription,
BookingTitle,
ConfirmRejectButtons,
HostAndAttendees,
MeetingLink,
TimeAndDateRow,
} from "./BookingListItemParts";
import type { BookingListItemProps } from "./types";
import { useBookingListItemData } from "./useBookingListItemData";
export const BookingListItem: React.FC<BookingListItemProps> = ({
booking,
@@ -18,144 +24,49 @@ export const BookingListItem: React.FC<BookingListItemProps> = ({
onReject,
onActionsPress,
}) => {
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isUpcoming = new Date(endTime) >= new Date();
const isPending = booking.status?.toUpperCase() === "PENDING";
const isCancelled = booking.status?.toUpperCase() === "CANCELLED";
const isRejected = booking.status?.toUpperCase() === "REJECTED";
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
const meetingInfo = getMeetingInfo(booking.location);
// Check if any attendee is marked as no-show
const hasNoShowAttendee = booking.attendees?.some(
(att: { noShow?: boolean; absent?: boolean }) => att.noShow === true || att.absent === true
);
const {
isPending,
isCancelled,
isRejected,
hostAndAttendeesDisplay,
meetingInfo,
hasNoShowAttendee,
formattedDate,
formattedTimeRange,
} = useBookingListItemData(booking, userEmail);
return (
<View className="border-b border-[#E5E5EA] bg-white">
<View className="border-b border-cal-border bg-cal-bg">
<TouchableOpacity
className="active:bg-[#F8F9FA]"
className="active:bg-cal-bg-secondary"
onPress={() => onPress(booking)}
onLongPress={() => onLongPress(booking)}
style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
>
{/* Time and Date Row */}
<View className="mb-2 flex-row flex-wrap items-center">
<Text className="text-sm font-medium text-[#333]">
{formatDate(startTime, isUpcoming)}
</Text>
<Text className="ml-2 text-sm text-[#666]">
{formatTime(startTime)} - {formatTime(endTime)}
</Text>
</View>
{/* Badges Row */}
<View className="mb-3 flex-row flex-wrap items-center">
{isPending ? (
<View className="mb-1 mr-2 rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
</View>
) : null}
</View>
{/* Title */}
<Text
className={`mb-2 text-lg font-medium leading-5 text-[#333] ${isCancelled || isRejected ? "line-through" : ""}`}
numberOfLines={2}
>
{booking.title}
</Text>
{/* Description */}
{booking.description ? (
<Text className="mb-2 text-sm leading-5 text-[#666]" numberOfLines={1}>
"{booking.description}"
</Text>
) : null}
{/* Host and Attendees */}
{hostAndAttendeesDisplay ? (
<View className="mb-2 flex-row items-center">
<Text className="text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
{hasNoShowAttendee && (
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-1.5 py-0.5">
<Ionicons name="eye-off" size={10} color="#DC2626" />
<Text className="ml-0.5 text-[10px] font-medium text-[#DC2626]">No-show</Text>
</View>
)}
</View>
) : null}
{/* Meeting Link */}
{meetingInfo ? (
<View className="mb-1 flex-row">
<TouchableOpacity
className="flex-row items-center"
style={{ alignSelf: "flex-start" }}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
onPress={async (e) => {
e.stopPropagation();
try {
await Linking.openURL(meetingInfo.cleanUrl);
} catch {
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
}
}}
>
{meetingInfo.iconUrl ? (
<SvgImage
uri={meetingInfo.iconUrl}
width={16}
height={16}
style={{ marginRight: 6 }}
/>
) : (
<Ionicons name="videocam" size={16} color="#007AFF" style={{ marginRight: 6 }} />
)}
<Text className="text-sm font-medium text-[#007AFF]">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
) : null}
<TimeAndDateRow formattedDate={formattedDate} formattedTimeRange={formattedTimeRange} />
<BadgesRow isPending={isPending} />
<BookingTitle title={booking.title} isCancelled={isCancelled} isRejected={isRejected} />
<BookingDescription description={booking.description} />
<HostAndAttendees
hostAndAttendeesDisplay={hostAndAttendeesDisplay}
hasNoShowAttendee={hasNoShowAttendee}
/>
<MeetingLink meetingInfo={meetingInfo} />
</TouchableOpacity>
<View
className="flex-row items-center justify-end"
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
>
{isPending ? (
<>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onConfirm(booking);
}}
>
<Ionicons name="checkmark" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Confirm</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onReject(booking);
}}
>
<Ionicons name="close" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Reject</Text>
</TouchableOpacity>
</>
) : null}
<ConfirmRejectButtons
booking={booking}
isPending={isPending}
isConfirming={isConfirming}
isDeclining={isDeclining}
onConfirm={onConfirm}
onReject={onReject}
/>
<TouchableOpacity
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
onPress={(e) => {
e.stopPropagation();
@@ -0,0 +1,179 @@
import { Ionicons } from "@expo/vector-icons";
import { Linking, Text, TouchableOpacity, View } from "react-native";
import { SvgImage } from "@/components/SvgImage";
import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import type { BookingListItemData } from "./useBookingListItemData";
interface TimeAndDateRowProps {
formattedDate: string;
formattedTimeRange: string;
}
export function TimeAndDateRow({ formattedDate, formattedTimeRange }: TimeAndDateRowProps) {
return (
<View className="mb-2 flex-row flex-wrap items-center">
<Text className="text-sm font-medium text-cal-text">{formattedDate}</Text>
<Text className="ml-2 text-sm text-cal-text-secondary">{formattedTimeRange}</Text>
</View>
);
}
interface BadgesRowProps {
isPending: boolean;
}
export function BadgesRow({ isPending }: BadgesRowProps) {
return (
<View className="mb-3 flex-row flex-wrap items-center">
{isPending ? (
<View className="mb-1 mr-2 rounded bg-cal-accent-warning px-2 py-0.5">
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
</View>
) : null}
</View>
);
}
interface BookingTitleProps {
title: string;
isCancelled: boolean;
isRejected: boolean;
}
export function BookingTitle({ title, isCancelled, isRejected }: BookingTitleProps) {
return (
<Text
className={`mb-2 text-lg font-medium leading-5 text-cal-text ${isCancelled || isRejected ? "line-through" : ""}`}
numberOfLines={2}
>
{title}
</Text>
);
}
interface BookingDescriptionProps {
description?: string | null;
}
export function BookingDescription({ description }: BookingDescriptionProps) {
if (!description) return null;
return (
<Text className="mb-2 text-sm leading-5 text-cal-text-secondary" numberOfLines={1}>
"{description}"
</Text>
);
}
interface HostAndAttendeesProps {
hostAndAttendeesDisplay: string | null;
hasNoShowAttendee: boolean;
}
export function HostAndAttendees({
hostAndAttendeesDisplay,
hasNoShowAttendee,
}: HostAndAttendeesProps) {
if (!hostAndAttendeesDisplay) return null;
return (
<View className="mb-2 flex-row items-center">
<Text className="text-sm text-cal-text">{hostAndAttendeesDisplay}</Text>
{hasNoShowAttendee && (
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-1.5 py-0.5">
<Ionicons name="eye-off" size={10} color="#DC2626" />
<Text className="ml-0.5 text-[10px] font-medium text-cal-accent-destructive">
No-show
</Text>
</View>
)}
</View>
);
}
interface MeetingLinkProps {
meetingInfo: BookingListItemData["meetingInfo"];
}
export function MeetingLink({ meetingInfo }: MeetingLinkProps) {
if (!meetingInfo) return null;
return (
<View className="mb-1 flex-row">
<TouchableOpacity
className="flex-row items-center"
style={{ alignSelf: "flex-start" }}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
onPress={async (e) => {
e.stopPropagation();
try {
await Linking.openURL(meetingInfo.cleanUrl);
} catch {
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
}
}}
>
{meetingInfo.iconUrl ? (
<SvgImage uri={meetingInfo.iconUrl} width={16} height={16} style={{ marginRight: 6 }} />
) : (
<Ionicons name="videocam" size={16} color="#007AFF" style={{ marginRight: 6 }} />
)}
<Text className="text-sm font-medium text-cal-accent">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
);
}
interface ConfirmRejectButtonsProps {
booking: Booking;
isPending: boolean;
isConfirming: boolean;
isDeclining: boolean;
onConfirm: (booking: Booking) => void;
onReject: (booking: Booking) => void;
}
export function ConfirmRejectButtons({
booking,
isPending,
isConfirming,
isDeclining,
onConfirm,
onReject,
}: ConfirmRejectButtonsProps) {
if (!isPending) return null;
return (
<>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-cal-border bg-cal-bg"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onConfirm(booking);
}}
>
<Ionicons name="checkmark" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-cal-text-emphasis">Confirm</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-cal-border bg-cal-bg"
style={{
paddingHorizontal: 12,
paddingVertical: 8,
opacity: isConfirming || isDeclining ? 0.5 : 1,
}}
disabled={isConfirming || isDeclining}
onPress={(e) => {
e.stopPropagation();
onReject(booking);
}}
>
<Ionicons name="close" size={16} color="#3C3F44" />
<Text className="ml-1 text-sm font-medium text-cal-text-emphasis">Reject</Text>
</TouchableOpacity>
</>
);
}
@@ -1,4 +1,4 @@
import type { Booking } from "../../services/calcom";
import type { Booking } from "@/services/calcom";
export interface BookingListItemProps {
booking: Booking;
@@ -0,0 +1,55 @@
import type { Booking } from "@/services/calcom";
import { formatDate, formatTime, getHostAndAttendeesDisplay } from "@/utils/bookings-utils";
import { getMeetingInfo } from "@/utils/meetings-utils";
export interface BookingListItemData {
startTime: string;
endTime: string;
isUpcoming: boolean;
isPending: boolean;
isCancelled: boolean;
isRejected: boolean;
hostAndAttendeesDisplay: string | null;
meetingInfo: ReturnType<typeof getMeetingInfo>;
hasNoShowAttendee: boolean;
formattedDate: string;
formattedTimeRange: string;
}
/**
* Extracts and computes derived data from a booking object.
* This hook centralizes the logic shared between iOS and non-iOS BookingListItem components.
*/
export function useBookingListItemData(booking: Booking, userEmail?: string): BookingListItemData {
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const isUpcoming = new Date(endTime) >= new Date();
const isPending = booking.status?.toUpperCase() === "PENDING";
const isCancelled = booking.status?.toUpperCase() === "CANCELLED";
const isRejected = booking.status?.toUpperCase() === "REJECTED";
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(booking, userEmail);
const meetingInfo = getMeetingInfo(booking.location);
const hasNoShowAttendee =
booking.attendees?.some(
(att: { noShow?: boolean; absent?: boolean }) => att.noShow === true || att.absent === true
) ?? false;
const formattedDate = formatDate(startTime, isUpcoming);
const formattedTimeRange = `${formatTime(startTime)} - ${formatTime(endTime)}`;
return {
startTime,
endTime,
isUpcoming,
isPending,
isCancelled,
isRejected,
hostAndAttendeesDisplay,
meetingInfo,
hasNoShowAttendee,
formattedDate,
formattedTimeRange,
};
}
@@ -2,7 +2,11 @@ import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { Activity, useMemo, useState } from "react";
import { Alert, FlatList, RefreshControl, ScrollView, Text, View } from "react-native";
import { useAuth } from "../../contexts/AuthContext";
import { BookingListItem } from "@/components/booking-list-item/BookingListItem";
import { BookingModals } from "@/components/booking-modals/BookingModals";
import { EmptyScreen } from "@/components/EmptyScreen";
import { LoadingSpinner } from "@/components/LoadingSpinner";
import { useAuth } from "@/contexts/AuthContext";
import {
type BookingFilter,
useBookingActions,
@@ -11,20 +15,16 @@ import {
useConfirmBooking,
useDeclineBooking,
useRescheduleBooking,
} from "../../hooks";
import type { Booking, EventType } from "../../services/calcom";
import type { ListItem } from "../../utils/bookings-utils";
} from "@/hooks";
import type { Booking, EventType } from "@/services/calcom";
import type { ListItem } from "@/utils/bookings-utils";
import {
filterByEventType,
getEmptyStateContent,
groupBookingsByMonth,
searchBookings,
} from "../../utils/bookings-utils";
import { offlineAwareRefresh } from "../../utils/network";
import { BookingListItem } from "../booking-list-item/BookingListItem";
import { BookingModals } from "../booking-modals/BookingModals";
import { EmptyScreen } from "../EmptyScreen";
import { LoadingSpinner } from "../LoadingSpinner";
} from "@/utils/bookings-utils";
import { offlineAwareRefresh } from "@/utils/network";
interface BookingListScreenProps {
// Platform-specific header rendering
@@ -10,11 +10,10 @@ import {
TouchableOpacity,
View,
} from "react-native";
import type { Booking, EventType } from "../../services/calcom";
import { type BookingActionsResult, getBookingActions } from "../../utils/booking-actions";
import { BookingActionsModal } from "../BookingActionsModal";
import { FullScreenModal } from "../FullScreenModal";
import { BookingActionsModal } from "@/components/BookingActionsModal";
import { FullScreenModal } from "@/components/FullScreenModal";
import type { Booking, EventType } from "@/services/calcom";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
// Empty actions result for when no booking is selected
const EMPTY_ACTIONS: BookingActionsResult = {
@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { Alert, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
import { openInAppBrowser } from "../../../utils/browser";
import { openInAppBrowser } from "@/utils/browser";
interface ConfigureOnWebCardProps {
title: string;
@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import type { Schedule } from "../../../services/calcom";
import type { Schedule } from "@/services/calcom";
interface DaySchedule {
day: string;
@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import { Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
import { LocationsList } from "../../../components/LocationsList";
import type { LocationItem, LocationOptionGroup } from "../../../types/locations";
import { slugify } from "../../../utils/slugify";
import { LocationsList } from "@/components/LocationsList";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
import { slugify } from "@/utils/slugify";
interface BasicsTabProps {
// Title, description, URL
@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
import { openInAppBrowser } from "../../../utils/browser";
import { openInAppBrowser } from "@/utils/browser";
interface RecurringTabProps {
recurringEnabled: boolean;
@@ -6,8 +6,8 @@
*/
// Re-export formatting utilities from centralized location
export { formatAppIdToDisplayName, formatDuration, truncateTitle } from "../../utils/formatters";
export { formatAppIdToDisplayName, formatDuration, truncateTitle } from "@/utils/formatters";
// Re-export location utilities from centralized location
export { displayNameToLocationValue } from "../../utils/locationHelpers";
export { displayNameToLocationValue } from "@/utils/locationHelpers";
// Re-export partial update utilities
export { buildPartialUpdatePayload, hasChanges } from "./utils/buildPartialUpdatePayload";
@@ -1,16 +1,12 @@
import type {
BookingLimitsCount,
BookingLimitsDuration,
EventType,
} from "../../../services/calcom";
import type { LocationItem } from "../../../types/locations";
import type { BookingLimitsCount, BookingLimitsDuration, EventType } from "@/services/calcom";
import type { LocationItem } from "@/types/locations";
import {
parseBufferTime,
parseFrequencyUnit,
parseMinimumNotice,
parseSlotInterval,
} from "../../../utils/eventTypeParsers";
import { mapItemToApiLocation } from "../../../utils/locationHelpers";
} from "@/utils/eventTypeParsers";
import { mapItemToApiLocation } from "@/utils/locationHelpers";
interface LocationInput {
type: string;
@@ -1,13 +1,16 @@
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 type React from "react";
import { Pressable, Text, View } from "react-native";
import { formatDuration } from "../../utils/formatters";
import { getEventDuration } from "../../utils/getEventDuration";
import { normalizeMarkdown } from "../../utils/normalizeMarkdown";
import { Pressable, View } from "react-native";
import {
DurationBadge,
EventTypeDescription,
EventTypeTitle,
PriceAndConfirmationBadges,
} from "./EventTypeListItemParts";
import type { EventTypeListItemProps } from "./types";
import { useEventTypeListItemData } from "./useEventTypeListItemData";
export const EventTypeListItem = ({
item,
@@ -22,7 +25,8 @@ export const EventTypeListItem = ({
onDuplicate,
onDelete,
}: EventTypeListItemProps) => {
const duration = getEventDuration(item);
const { formattedDuration, normalizedDescription, hasPrice, formattedPrice } =
useEventTypeListItemData(item);
const isLast = index === filteredEventTypes.length - 1;
type ButtonSystemImage = React.ComponentProps<typeof Button>["systemImage"];
@@ -67,7 +71,9 @@ export const EventTypeListItem = ({
];
return (
<View className={`bg-white active:bg-[#F8F9FA] ${!isLast ? "border-b border-[#E5E5EA]" : ""}`}>
<View
className={`bg-cal-bg active:bg-cal-bg-secondary ${!isLast ? "border-b border-cal-border" : ""}`}
>
<View className="flex-shrink-1 flex-row items-center justify-between">
<Pressable
onPress={() => handleEventTypePress(item)}
@@ -75,35 +81,14 @@ export const EventTypeListItem = ({
className="flex-grow"
>
<View className="mr-4 flex-1">
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title} </Text>
</View>
{item.description ? (
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
{normalizeMarkdown(item.description)}
</Text>
) : null}
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000" />
<Text className="ml-1.5 text-xs font-semibold text-black">
{formatDuration(duration)}
</Text>
</View>
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
<View className="mt-2 flex-row items-center gap-3">
{item.price != null && item.price > 0 ? (
<Text className="text-sm font-medium text-[#34C759]">
{item.currency || "$"}
{item.price}
</Text>
) : null}
{item.requiresConfirmation ? (
<View className="rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
</View>
) : null}
</View>
) : null}
<EventTypeTitle title={item.title} />
<EventTypeDescription normalizedDescription={normalizedDescription} />
<DurationBadge formattedDuration={formattedDuration} />
<PriceAndConfirmationBadges
hasPrice={hasPrice}
formattedPrice={formattedPrice}
requiresConfirmation={item.requiresConfirmation}
/>
</View>
</Pressable>
@@ -1,11 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import Svg, { Path } from "react-native-svg";
import { Tooltip } from "../../components/Tooltip";
import { formatDuration } from "../../utils/formatters";
import { getEventDuration } from "../../utils/getEventDuration";
import { normalizeMarkdown } from "../../utils/normalizeMarkdown";
import { TouchableOpacity, View } from "react-native";
import {
DurationBadge,
EventTypeActions,
EventTypeDescription,
EventTypeTitle,
PriceAndConfirmationBadges,
} from "./EventTypeListItemParts";
import type { EventTypeListItemProps } from "./types";
import { useEventTypeListItemData } from "./useEventTypeListItemData";
export const EventTypeListItem = ({
item,
@@ -17,93 +19,35 @@ export const EventTypeListItem = ({
handleCopyLink,
handlePreview,
}: EventTypeListItemProps) => {
const duration = getEventDuration(item);
const { formattedDuration, normalizedDescription, hasPrice, formattedPrice } =
useEventTypeListItemData(item);
const isLast = index === filteredEventTypes.length - 1;
return (
<TouchableOpacity
className={`bg-white active:bg-[#F8F9FA] ${!isLast ? "border-b border-[#E5E5EA]" : ""}`}
className={`bg-cal-bg active:bg-cal-bg-secondary ${!isLast ? "border-b border-cal-border" : ""}`}
onPress={() => handleEventTypePress(item)}
onLongPress={() => handleEventTypeLongPress(item)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<View className="mr-4 flex-1">
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title}</Text>
</View>
{item.description ? (
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
{normalizeMarkdown(item.description)}
</Text>
) : null}
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000" />
<Text className="ml-1.5 text-xs font-semibold text-black">
{formatDuration(duration)}
</Text>
</View>
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
<View className="mt-2 flex-row items-center gap-3">
{item.price != null && item.price > 0 ? (
<Text className="text-sm font-medium text-[#34C759]">
{item.currency || "$"}
{item.price}
</Text>
) : null}
{item.requiresConfirmation ? (
<View className="rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
</View>
) : null}
</View>
) : null}
</View>
<View className="flex-row">
<Tooltip text="Preview">
<TouchableOpacity
className="items-center justify-center rounded-l-lg border border-r-0 border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handlePreview(item)}
>
<Ionicons name="open-outline" size={18} color="#3C3F44" />
</TouchableOpacity>
</Tooltip>
<Tooltip text={copiedEventTypeId === item.id ? "Copied!" : "Copy link"}>
<TouchableOpacity
className="items-center justify-center border border-r-0 border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handleCopyLink(item)}
>
{copiedEventTypeId === item.id ? (
<Ionicons name="checkmark" size={18} color="#10B981" />
) : (
<Svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="#3C3F44"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<Path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</Svg>
)}
</TouchableOpacity>
</Tooltip>
<Tooltip text="More">
<TouchableOpacity
className="items-center justify-center rounded-r-lg border border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handleEventTypeLongPress(item)}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
</Tooltip>
<EventTypeTitle title={item.title} />
<EventTypeDescription normalizedDescription={normalizedDescription} />
<DurationBadge formattedDuration={formattedDuration} />
<PriceAndConfirmationBadges
hasPrice={hasPrice}
formattedPrice={formattedPrice}
requiresConfirmation={item.requiresConfirmation}
/>
</View>
<EventTypeActions
item={item}
copiedEventTypeId={copiedEventTypeId}
handleCopyLink={handleCopyLink}
handlePreview={handlePreview}
handleEventTypeLongPress={handleEventTypeLongPress}
/>
</View>
</TouchableOpacity>
);
@@ -0,0 +1,133 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import Svg, { Path } from "react-native-svg";
import { Tooltip } from "@/components/Tooltip";
import type { EventType } from "@/services/types/event-types.types";
interface EventTypeTitleProps {
title: string;
}
export function EventTypeTitle({ title }: EventTypeTitleProps) {
return (
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-cal-text">{title}</Text>
</View>
);
}
interface EventTypeDescriptionProps {
normalizedDescription: string | null;
}
export function EventTypeDescription({ normalizedDescription }: EventTypeDescriptionProps) {
if (!normalizedDescription) return null;
return (
<Text className="mb-2 mt-0.5 text-sm leading-5 text-cal-text-secondary" numberOfLines={2}>
{normalizedDescription}
</Text>
);
}
interface DurationBadgeProps {
formattedDuration: string;
}
export function DurationBadge({ formattedDuration }: DurationBadgeProps) {
return (
<View className="mt-2 flex-row items-center self-start rounded-lg border border-cal-border bg-cal-border px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000000" />
<Text className="ml-1.5 text-xs font-semibold text-cal-brand-black">{formattedDuration}</Text>
</View>
);
}
interface PriceAndConfirmationBadgesProps {
hasPrice: boolean;
formattedPrice: string | null;
requiresConfirmation?: boolean;
}
export function PriceAndConfirmationBadges({
hasPrice,
formattedPrice,
requiresConfirmation,
}: PriceAndConfirmationBadgesProps) {
if (!hasPrice && !requiresConfirmation) return null;
return (
<View className="mt-2 flex-row items-center gap-3">
{hasPrice && formattedPrice ? (
<Text className="text-sm font-medium text-cal-accent-success">{formattedPrice}</Text>
) : null}
{requiresConfirmation ? (
<View className="rounded bg-cal-accent-warning px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
</View>
) : null}
</View>
);
}
interface EventTypeActionsProps {
item: EventType;
copiedEventTypeId: number | null;
handleCopyLink: (item: EventType) => void;
handlePreview: (item: EventType) => void;
handleEventTypeLongPress: (item: EventType) => void;
}
export function EventTypeActions({
item,
copiedEventTypeId,
handleCopyLink,
handlePreview,
handleEventTypeLongPress,
}: EventTypeActionsProps) {
return (
<View className="flex-row">
<Tooltip text="Preview">
<TouchableOpacity
className="items-center justify-center rounded-l-lg border border-r-0 border-cal-border"
style={{ width: 32, height: 32 }}
onPress={() => handlePreview(item)}
>
<Ionicons name="open-outline" size={18} color="#3C3F44" />
</TouchableOpacity>
</Tooltip>
<Tooltip text={copiedEventTypeId === item.id ? "Copied!" : "Copy link"}>
<TouchableOpacity
className="items-center justify-center border border-r-0 border-cal-border"
style={{ width: 32, height: 32 }}
onPress={() => handleCopyLink(item)}
>
{copiedEventTypeId === item.id ? (
<Ionicons name="checkmark" size={18} color="#34C759" />
) : (
<Svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="#3C3F44"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<Path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</Svg>
)}
</TouchableOpacity>
</Tooltip>
<Tooltip text="More">
<TouchableOpacity
className="items-center justify-center rounded-r-lg border border-cal-border"
style={{ width: 32, height: 32 }}
onPress={() => handleEventTypeLongPress(item)}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
</Tooltip>
</View>
);
}
@@ -1,4 +1,4 @@
import type { EventType } from "../../services/types/event-types.types";
import type { EventType } from "@/services/types/event-types.types";
export interface EventTypeListItemProps {
item: EventType;
@@ -0,0 +1,32 @@
import type { EventType } from "@/services/types/event-types.types";
import { formatDuration } from "@/utils/formatters";
import { getEventDuration } from "@/utils/getEventDuration";
import { normalizeMarkdown } from "@/utils/normalizeMarkdown";
export interface EventTypeListItemData {
duration: number;
formattedDuration: string;
normalizedDescription: string | null;
hasPrice: boolean;
formattedPrice: string | null;
}
/**
* Extracts and computes derived data from an event type object.
* This hook centralizes the logic shared between iOS and non-iOS EventTypeListItem components.
*/
export function useEventTypeListItemData(item: EventType): EventTypeListItemData {
const duration = getEventDuration(item);
const formattedDuration = formatDuration(duration);
const normalizedDescription = item.description ? normalizeMarkdown(item.description) : null;
const hasPrice = item.price != null && item.price > 0;
const formattedPrice = hasPrice ? `${item.currency || "$"}${item.price}` : null;
return {
duration,
formattedDuration,
normalizedDescription,
hasPrice,
formattedPrice,
};
}
@@ -18,8 +18,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Booking } from "../../services/calcom";
import { CalComAPIService } from "../../services/calcom";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
export interface AddGuestsScreenProps {
booking: Booking | null;
@@ -4,11 +4,11 @@ import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, ScrollView, Switch, Text, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, type Schedule } from "../../services/calcom";
import type { ScheduleAvailability } from "../../services/types";
import { showErrorAlert } from "../../utils/alerts";
import { AppPressable } from "../AppPressable";
import { FullScreenModal } from "../FullScreenModal";
import { AppPressable } from "@/components/AppPressable";
import { FullScreenModal } from "@/components/FullScreenModal";
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"];
const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -12,23 +12,23 @@ import {
TextInput,
View,
} from "react-native";
import { AppPressable } from "../../components/AppPressable";
import { AvailabilityListItem } from "../../components/availability-list-item/AvailabilityListItem";
import { EmptyScreen } from "../../components/EmptyScreen";
import { FullScreenModal } from "../../components/FullScreenModal";
import { Header } from "../../components/Header";
import { LoadingSpinner } from "../../components/LoadingSpinner";
import { AppPressable } from "@/components/AppPressable";
import { AvailabilityListItem } from "@/components/availability-list-item/AvailabilityListItem";
import { EmptyScreen } from "@/components/EmptyScreen";
import { FullScreenModal } from "@/components/FullScreenModal";
import { Header } from "@/components/Header";
import { LoadingSpinner } from "@/components/LoadingSpinner";
import {
useCreateSchedule,
useDeleteSchedule,
useDuplicateSchedule,
useSchedules,
useSetScheduleAsDefault,
} from "../../hooks";
import { CalComAPIService, type Schedule } from "../../services/calcom";
import { showErrorAlert } from "../../utils/alerts";
import { offlineAwareRefresh } from "../../utils/network";
import { shadows } from "../../utils/shadows";
} from "@/hooks";
import { CalComAPIService, type Schedule } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { offlineAwareRefresh } from "@/utils/network";
import { shadows } from "@/utils/shadows";
export interface AvailabilityListScreenProps {
searchQuery: string;
@@ -2,17 +2,17 @@ import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Alert, Platform, ScrollView, Text, View } from "react-native";
import { useAuth } from "../../contexts/AuthContext";
import { type Booking, CalComAPIService } from "../../services/calcom";
import { showErrorAlert } from "../../utils/alerts";
import { type BookingActionsResult, getBookingActions } from "../../utils/booking-actions";
import { openInAppBrowser } from "../../utils/browser";
import { defaultLocations, getDefaultLocationIconUrl } from "../../utils/defaultLocations";
import { formatAppIdToDisplayName } from "../../utils/formatters";
import { getAppIconUrl } from "../../utils/getAppIconUrl";
import { AppPressable } from "../AppPressable";
import { BookingActionsModal } from "../BookingActionsModal";
import { SvgImage } from "../SvgImage";
import { AppPressable } from "@/components/AppPressable";
import { BookingActionsModal } from "@/components/BookingActionsModal";
import { SvgImage } from "@/components/SvgImage";
import { useAuth } from "@/contexts/AuthContext";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { openInAppBrowser } from "@/utils/browser";
import { defaultLocations, getDefaultLocationIconUrl } from "@/utils/defaultLocations";
import { formatAppIdToDisplayName } from "@/utils/formatters";
import { getAppIconUrl } from "@/utils/getAppIconUrl";
// Empty actions result for when no booking is loaded
const EMPTY_ACTIONS: BookingActionsResult = {
@@ -12,8 +12,8 @@ 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 type { Booking } from "../../services/calcom";
import { CalComAPIService } from "../../services/calcom";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
// Location types configuration
export const LOCATION_TYPES = {
@@ -29,8 +29,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Booking } from "../../services/calcom";
import { CalComAPIService } from "../../services/calcom";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
export const LOCATION_TYPES = {
link: {
@@ -9,8 +9,8 @@ import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { ActivityIndicator, Alert, FlatList, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Booking } from "../../services/calcom";
import { CalComAPIService } from "../../services/calcom";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
interface Attendee {
id?: number | string;
@@ -8,7 +8,7 @@
import { Ionicons } from "@expo/vector-icons";
import { FlatList, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { ConferencingSession } from "../../services/types/bookings.types";
import type { ConferencingSession } from "@/services/types/bookings.types";
export interface MeetingSessionDetailsScreenProps {
sessions: ConferencingSession[];
@@ -19,8 +19,8 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Booking } from "../../services/calcom";
import { CalComAPIService } from "../../services/calcom";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
// Note: @expo/ui DateTimePicker components are not yet stable
// Using a simple inline picker approach instead
@@ -9,7 +9,7 @@ import { Ionicons } from "@expo/vector-icons";
import * as WebBrowser from "expo-web-browser";
import { FlatList, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { BookingRecording } from "../../services/types/bookings.types";
import type { BookingRecording } from "@/services/types/bookings.types";
export interface ViewRecordingsScreenProps {
recordings: BookingRecording[];
+5 -5
View File
@@ -1,13 +1,13 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
import { CalComAPIService } from "../services/calcom";
import { CalComAPIService } from "@/services/calcom";
import {
type CalComOAuthService,
createCalComOAuthService,
type OAuthTokens,
} from "../services/oauthService";
import type { UserProfile } from "../services/types/users.types";
import { WebAuthService } from "../services/webAuth";
import { secureStorage } from "../utils/storage";
} from "@/services/oauthService";
import type { UserProfile } from "@/services/types/users.types";
import { WebAuthService } from "@/services/webAuth";
import { secureStorage } from "@/utils/storage";
/**
* Simplified user info stored in auth context
+2 -2
View File
@@ -12,8 +12,8 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import React, { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { CACHE_CONFIG } from "../config/cache.config";
import { clearQueryCache, createQueryPersister } from "../utils/queryPersister";
import { CACHE_CONFIG } from "@/config/cache.config";
import { clearQueryCache, createQueryPersister } from "@/utils/queryPersister";
/**
* Create and configure the QueryClient instance
+2 -2
View File
@@ -11,9 +11,9 @@
*/
// Re-export query keys for advanced use cases
export { queryKeys } from "../config/cache.config";
export { queryKeys } from "@/config/cache.config";
// Re-export query context utilities
export { useQueryContext } from "../contexts/QueryContext";
export { useQueryContext } from "@/contexts/QueryContext";
// UI State Management hooks
export {
type BookingFilter,
+4 -4
View File
@@ -8,14 +8,14 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { Alert, Linking } from "react-native";
import { queryKeys } from "../config/cache.config";
import { type Booking, CalComAPIService } from "../services/calcom";
import { queryKeys } from "@/config/cache.config";
import { type Booking, CalComAPIService } from "@/services/calcom";
import type {
AddGuestInput,
BookingRecording,
ConferencingSession,
} from "../services/types/bookings.types";
import { showErrorAlert } from "../utils/alerts";
} from "@/services/types/bookings.types";
import { showErrorAlert } from "@/utils/alerts";
interface UseBookingActionModalsReturn {
// Selected booking for actions
+2 -2
View File
@@ -1,8 +1,8 @@
import type { useRouter } from "expo-router";
import { useState } from "react";
import { Alert } from "react-native";
import type { Booking } from "../services/calcom";
import { showErrorAlert } from "../utils/alerts";
import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
interface UseBookingActionsParams {
router: ReturnType<typeof useRouter>;
+3 -3
View File
@@ -6,14 +6,14 @@
*/
import { useMemo } from "react";
import type { Booking } from "../services/calcom";
import type { EventType } from "../services/types/event-types.types";
import type { Booking } from "@/services/calcom";
import type { EventType } from "@/services/types/event-types.types";
import {
type BookingActionsResult,
getBookingActions,
type NormalizedBooking,
normalizeBooking,
} from "../utils/booking-actions";
} from "@/utils/booking-actions";
interface UseBookingActionsGatingParams {
booking: Booking | null;
+2 -2
View File
@@ -10,8 +10,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CACHE_CONFIG, queryKeys } from "../config/cache.config";
import { type Booking, CalComAPIService } from "../services/calcom";
import { CACHE_CONFIG, queryKeys } from "@/config/cache.config";
import { type Booking, CalComAPIService } from "@/services/calcom";
/**
* Filter options for fetching bookings
+2 -2
View File
@@ -10,8 +10,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CACHE_CONFIG, queryKeys } from "../config/cache.config";
import { CalComAPIService, type CreateEventTypeInput, type EventType } from "../services/calcom";
import { CACHE_CONFIG, queryKeys } from "@/config/cache.config";
import { CalComAPIService, type CreateEventTypeInput, type EventType } from "@/services/calcom";
/**
* Hook to fetch all event types
+2 -2
View File
@@ -10,8 +10,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CACHE_CONFIG, queryKeys } from "../config/cache.config";
import { CalComAPIService, type Schedule } from "../services/calcom";
import { CACHE_CONFIG, queryKeys } from "@/config/cache.config";
import { CalComAPIService, type Schedule } from "@/services/calcom";
/**
* Sort schedules: default first, then alphabetically by name
+2 -2
View File
@@ -8,8 +8,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CACHE_CONFIG, queryKeys } from "../config/cache.config";
import { CalComAPIService, type UserProfile } from "../services/calcom";
import { CACHE_CONFIG, queryKeys } from "@/config/cache.config";
import { CalComAPIService, type UserProfile } from "@/services/calcom";
/**
* User profile update input type
+1 -1
View File
@@ -8,4 +8,4 @@ export {
createQueryPersister,
getCacheMetadata,
storage,
} from "../utils/queryPersister";
} from "@/utils/queryPersister";
+1 -1
View File
@@ -8,4 +8,4 @@ export {
isChromeStorageAvailable,
type StorageAdapter,
secureStorage,
} from "../utils/storage";
} from "@/utils/storage";
+1 -1
View File
@@ -1,4 +1,4 @@
const path = require("path");
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
+2
View File
@@ -66,6 +66,8 @@
"expo-dev-client": "6.1.0-canary-20251216-3f01dbf",
"expo-device": "8.0.11-canary-20251216-3f01dbf",
"expo-glass-effect": "0.2.0-canary-20251216-3f01dbf",
"expo-haptics": "^15.0.8",
"expo-image": "^3.0.11",
"expo-linking": "8.0.11-canary-20251216-3f01dbf",
"expo-router": "7.0.0-canary-20251216-3f01dbf",
"expo-secure-store": "15.0.9-canary-20251216-3f01dbf",
+35 -1
View File
@@ -4,7 +4,41 @@ module.exports = {
presets: [require("nativewind/preset")],
darkMode: "class",
theme: {
extend: {},
extend: {
colors: {
cal: {
text: {
DEFAULT: "#333333",
secondary: "#666666",
muted: "#8E8E93",
emphasis: "#3C3F44",
},
bg: {
DEFAULT: "#FFFFFF",
secondary: "#F8F9FA",
muted: "#F2F2F7",
},
border: {
DEFAULT: "#E5E5EA",
light: "#C6C6C8",
},
accent: {
DEFAULT: "#007AFF",
success: "#34C759",
warning: "#FF9500",
error: "#FF3B30",
destructive: "#DC2626",
},
brand: {
DEFAULT: "#292929",
black: "#000000",
},
},
},
spacing: {
18: "4.5rem",
},
},
},
plugins: [],
};
+12 -1
View File
@@ -11,7 +11,18 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["chrome"]
"types": ["chrome"],
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["./components/*"],
"@/hooks/*": ["./hooks/*"],
"@/utils/*": ["./utils/*"],
"@/services/*": ["./services/*"],
"@/contexts/*": ["./contexts/*"],
"@/config/*": ["./config/*"],
"@/types/*": ["./types/*"]
}
},
"include": ["extension/**/*.ts", "extension/**/*.tsx", "services/types/**/*.ts", "types/**/*.ts"],
"exclude": ["node_modules", ".output"]
+12 -1
View File
@@ -1,7 +1,18 @@
{
"compilerOptions": {
"types": ["nativewind/types"],
"strict": true
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["./components/*"],
"@/hooks/*": ["./hooks/*"],
"@/utils/*": ["./utils/*"],
"@/services/*": ["./services/*"],
"@/contexts/*": ["./contexts/*"],
"@/config/*": ["./config/*"],
"@/types/*": ["./types/*"]
}
},
"extends": "expo/tsconfig.base",
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"],
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Booking } from "../services/types/bookings.types";
import type { Booking } from "@/services/types/bookings.types";
/**
* Attendee type extracted from Booking for reuse across the codebase.
+2 -2
View File
@@ -11,8 +11,8 @@
* Deferred:
* - report, reroute, reassign, charge_card
*/
import type { Booking } from "../services/types/bookings.types";
import type { EventType } from "../services/types/event-types.types";
import type { Booking } from "@/services/types/bookings.types";
import type { EventType } from "@/services/types/event-types.types";
// ============================================================================
// Types
+2 -2
View File
@@ -1,5 +1,5 @@
import type { BookingFilter } from "../hooks";
import type { Booking } from "../services/calcom";
import type { BookingFilter } from "@/hooks";
import type { Booking } from "@/services/calcom";
export const getEmptyStateContent = (activeFilter: BookingFilter) => {
switch (activeFilter) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { EventType } from "../hooks";
import type { EventType } from "@/hooks";
export const getEventDuration = (eventType: EventType): number => {
// Prefer lengthInMinutes (API field), fallback to length for backwards compatibility
+1 -1
View File
@@ -9,7 +9,7 @@ import type {
LocationItem,
LocationOption,
LocationOptionGroup,
} from "../types/locations";
} from "@/types/locations";
import { DefaultLocationType, defaultLocations } from "./defaultLocations";
import { formatAppIdToDisplayName } from "./formatters";
import { getAppIconUrl } from "./getAppIconUrl";
+1 -1
View File
@@ -5,7 +5,7 @@
*/
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { CACHE_CONFIG } from "../config/cache.config";
import { CACHE_CONFIG } from "@/config/cache.config";
import { generalStorage } from "./storage";
// Use the shared general storage adapter for cache persistence