Files
cal.diy/companion/components/Header.tsx
T
Dhairyashil Shinde 9e84f59216 feat(companion): UI Enhancements for Android and Extension (#26434)
* feat: companion-android-ui-upgrade version 1

* recurrings and unconfirmed booking filter and page implementation

* add badge and links to event type list page

* address cubics comments

* feat(companion): unify dropdown menu for Android and extension (#26486)

* feat(companion): unify dropdown menu for Android and extension

- Merge Android-specific dropdown implementations into base component files
- EventTypeListItem: Add DropdownMenu with Preview, Copy link, Edit, Duplicate, Delete actions
- BookingListItem: Add DropdownMenu with booking actions (reschedule, edit location, add guests, etc.)
- RecurringBookingListItem: Add DropdownMenu with recurring booking actions
- AvailabilityListItem: Add DropdownMenu with Set as Default, Duplicate, Delete actions
- BookingDetailScreen: Add DropdownMenu in header for Android with AlertDialog for cancel confirmation
- Delete all .android.tsx files as implementations are now unified

* fix(companion): fix typecheck errors after dropdown unification

- Remove unused props from EventTypeListItem.ios.tsx (copiedEventTypeId, handleEventTypeLongPress)
- Remove unused onActionsPress prop from BookingListItem.ios.tsx
- Remove copiedEventTypeId and handleEventTypeLongPress props from index.ios.tsx and index.tsx
- Remove onLongPress and onActionsPress props from BookingListScreen.tsx
- Remove handleScheduleLongPress, setSelectedSchedule, setShowActionsModal props from AvailabilityListScreen.tsx
- Fix BookingDetailScreen.tsx to use correct action property names (reschedule.visible instead of canReschedule, etc.)

* fix(companion): remove unused code after dropdown unification

- Remove unused handleEventTypeLongPress function and ActionSheetIOS import from index.ios.tsx
- Remove unused copiedEventTypeId state, handleEventTypeLongPress function, and ActionSheetIOS import from index.tsx
- Prefix unused setSelectedBooking with underscore in BookingListScreen.tsx
- Remove unused handleScheduleLongPress function and ActionSheetIOS import from AvailabilityListScreen.tsx

* feat(companion): unify booking filter UI for Android and extension

- Remove SegmentedControl from web/extension booking list page
- Use Header dropdown for booking status filter on both Android and web
- Use unified event type filter dropdown for both platforms
- Remove unused showFilterModal state and related code
- Pass filterOptions, activeFilter, and onFilterChange to Header for all platforms

* fix(companion): add header padding for web/extension to prevent button clipping

- Add headerLeftContainerStyle and headerRightContainerStyle with 12px padding for web platform
- Applied to root Stack and all nested tab Stack layouts
- Fixes issue where header buttons were touching edges and getting chopped off on extension/web
- Android remains unaffected as the fix is web-only

* fix(companion): add HeaderButtonWrapper for web-only header padding

- Create HeaderButtonWrapper component that adds 12px margin on web only
- Wrap all header buttons with HeaderButtonWrapper to prevent clipping
- Revert invalid screenOptions changes that caused typecheck errors
- Apply fix to all screens with native header buttons:
  - reschedule.tsx, edit-location.tsx, add-guests.tsx
  - mark-no-show.tsx, view-recordings.tsx, meeting-session-details.tsx
  - event-type-detail.tsx, BookingDetailScreen.tsx, profile-sheet.tsx
  - edit-availability-day.tsx, edit-availability-name.tsx, edit-availability-override.tsx

* update more ui-ux

* address cubics comments

* address cubics comments & open event type list page first for inttial render of app
2026-01-07 07:19:07 -03:00

438 lines
17 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Image, Platform, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, type UserProfile } from "@/services/calcom";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { CalComLogo } from "./CalComLogo";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppPressable } from "@/components/AppPressable";
import type { EventTypeFilters, EventTypeSortOption } from "@/hooks/useEventTypeFilter";
interface FilterOption {
key: string;
label: string;
}
interface EventTypeFilterConfig {
sortBy: EventTypeSortOption;
filters: EventTypeFilters;
onSortChange: (sort: EventTypeSortOption) => void;
onToggleFilter: (filterKey: keyof EventTypeFilters) => void;
onResetFilters: () => void;
activeFilterCount: number;
}
interface HeaderProps {
/** Optional: Filter options for dropdown menu (e.g., booking status filter) */
filterOptions?: FilterOption[];
/** Optional: Currently active filter key */
activeFilter?: string;
/** Optional: Callback when filter changes */
onFilterChange?: (filterKey: string) => void;
/** Optional: Event type filter/sort config for Android */
eventTypeFilterConfig?: EventTypeFilterConfig;
}
export function Header({
filterOptions,
activeFilter,
onFilterChange,
eventTypeFilterConfig,
}: HeaderProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const fetchUserProfile = useCallback(async () => {
try {
const profile = await CalComAPIService.getUserProfile();
setUserProfile(profile);
setLoading(false);
} catch (err) {
console.error("Failed to fetch user profile");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
console.debug("[Header] fetchUserProfile failed", { message, stack });
}
setLoading(false);
}
}, []);
useEffect(() => {
fetchUserProfile();
}, [fetchUserProfile]);
const handleProfile = () => {
// Navigate to profile sheet on all platforms (Android, Web, Extension)
router.push("/profile-sheet");
};
const activeFilterLabel =
filterOptions?.find((opt) => opt.key === activeFilter)?.label ?? "Filter";
return (
<View
className="flex-row items-center justify-between border-b border-[#E5E5EA] bg-white px-2 md:px-4"
style={{ paddingTop: insets.top + 4, paddingBottom: 4 }}
>
{/* Left: Cal.com Logo */}
<View className="ms-1">
<CalComLogo width={101} height={22} color="#333" />
</View>
{/* Right: Filter Dropdown + Profile */}
<View
className="flex-row items-center gap-2"
style={Platform.OS === "web" ? { marginRight: 8 } : {}}
>
{/* Booking status filter dropdown (for bookings page) */}
{filterOptions && filterOptions.length > 0 && onFilterChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="flex-row items-center gap-1 px-2 py-2">
<Text className="text-[16px] font-semibold text-[#007AFF]">
{activeFilterLabel}
</Text>
<Ionicons name="chevron-down" size={16} color="#007AFF" />
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-44"
align="end"
>
{filterOptions.map((option) => {
const isSelected = activeFilter === option.key;
// Map filter keys to appropriate icons
const getFilterIcon = (key: string) => {
switch (key) {
case "upcoming":
return "calendar-outline";
case "unconfirmed":
return "help-circle-outline";
case "recurring":
return "repeat-outline";
case "past":
return "checkmark-circle-outline";
case "cancelled":
return "close-circle-outline";
default:
return "calendar-outline";
}
};
return (
<DropdownMenuItem key={option.key} onPress={() => onFilterChange(option.key)}>
<View className="flex-row items-center gap-2">
<Ionicons
name={isSelected ? "checkmark-circle" : getFilterIcon(option.key)}
size={16}
color={isSelected ? "#007AFF" : "#666"}
/>
<Text
className={
isSelected ? "text-base font-semibold text-[#007AFF]" : "text-base"
}
>
{option.label}
</Text>
</View>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Event type filter/sort menu (for event types page - Android and Web/Extension) */}
{eventTypeFilterConfig && (Platform.OS === "android" || Platform.OS === "web") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="relative p-2">
<Ionicons name="options-outline" size={22} color="#007AFF" />
{/* Badge for active filters */}
{eventTypeFilterConfig.activeFilterCount > 0 && (
<View
className="absolute -top-0.5 -right-0.5 items-center justify-center rounded-full bg-[#007AFF]"
style={{ width: 16, height: 16 }}
>
<Text className="text-[10px] font-bold text-white">
{eventTypeFilterConfig.activeFilterCount}
</Text>
</View>
)}
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-56"
align="end"
>
{/* Sort by Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<View className="flex-row items-center gap-2">
<Ionicons name="swap-vertical-outline" size={16} color="#666" />
<Text className="text-base">Sort by</Text>
</View>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onSortChange("alphabetical")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "alphabetical"
? "checkmark-circle"
: "text-outline"
}
size={16}
color={eventTypeFilterConfig.sortBy === "alphabetical" ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "alphabetical"
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Alphabetical
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onSortChange("newest")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "newest"
? "checkmark-circle"
: "calendar-outline"
}
size={16}
color={eventTypeFilterConfig.sortBy === "newest" ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "newest"
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Newest First
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onSortChange("duration")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "duration"
? "checkmark-circle"
: "time-outline"
}
size={16}
color={eventTypeFilterConfig.sortBy === "duration" ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "duration"
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
By Duration
</Text>
</View>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{/* Filter Label */}
<DropdownMenuLabel>
<View className="flex-row items-center gap-2">
<Ionicons name="filter-outline" size={16} color="#666" />
<Text className="text-sm font-medium text-gray-500">Filters</Text>
</View>
</DropdownMenuLabel>
{/* Filter Toggles (multi-select) */}
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("hiddenOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.hiddenOnly
? "checkmark-circle"
: "eye-off-outline"
}
size={16}
color={eventTypeFilterConfig.filters.hiddenOnly ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.filters.hiddenOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Hidden Only
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("paidOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.paidOnly ? "checkmark-circle" : "cash-outline"
}
size={16}
color={eventTypeFilterConfig.filters.paidOnly ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.filters.paidOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Paid Events
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("seatedOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.seatedOnly
? "checkmark-circle"
: "people-outline"
}
size={16}
color={eventTypeFilterConfig.filters.seatedOnly ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.filters.seatedOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Seated Events
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onToggleFilter("requiresConfirmationOnly")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? "checkmark-circle"
: "checkmark-circle-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.requiresConfirmationOnly ? "#007AFF" : "#666"
}
/>
<Text
className={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Requires Confirmation
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onToggleFilter("recurringOnly")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.recurringOnly
? "checkmark-circle"
: "repeat-outline"
}
size={16}
color={eventTypeFilterConfig.filters.recurringOnly ? "#007AFF" : "#666"}
/>
<Text
className={
eventTypeFilterConfig.filters.recurringOnly
? "text-base font-semibold text-[#007AFF]"
: "text-base"
}
>
Recurring
</Text>
</View>
</DropdownMenuItem>
{/* Clear All - only show when filters are active */}
{eventTypeFilterConfig.activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onPress={eventTypeFilterConfig.onResetFilters}>
<View className="flex-row items-center gap-2">
<Ionicons name="close-circle-outline" size={16} color="#FF3B30" />
<Text className="text-base text-[#FF3B30]">Clear All Filters</Text>
</View>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Profile Picture */}
<TouchableOpacity onPress={handleProfile} className="p-1">
{loading ? (
<ActivityIndicator size="small" color="#666" />
) : userProfile?.avatarUrl ? (
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
className="h-8 w-8 rounded-full"
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
) : (
<View
className="items-center justify-center rounded-full bg-[#E5E5EA]"
style={{ width: 32, height: 32 }}
>
<Ionicons name="person-outline" size={20} color="#666" />
</View>
)}
</TouchableOpacity>
</View>
</View>
);
}