mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* feat(companion): add dark mode support using NativeWind system preference - Set userInterfaceStyle to 'automatic' in app.json to follow system preference - Add useColorScheme hook from NativeWind to detect color scheme - Update root layout with dynamic colors for StatusBar, containers, and modals - Update tabs layout with dynamic colors for tab bar and icons - Update all Stack.Screen options to use dynamic background colors and blur effects - Support both iOS and Android with appropriate dark mode colors Co-Authored-By: [email protected] <[email protected]> * fix(companion): use NativeWind dark: variant classes for automatic dark mode Use dark: Tailwind variant classes instead of conditional logic based on useColorScheme for the container background. This ensures NativeWind automatically applies dark mode styles based on system preference. Co-Authored-By: [email protected] <[email protected]> * fix(companion): use darkMode 'media' for automatic dark mode detection - Changed darkMode from 'class' to 'media' in tailwind.config.js - Updated global.css to use @media (prefers-color-scheme: dark) instead of .dark selector - This ensures NativeWind properly detects system color scheme on iOS/Android Co-Authored-By: [email protected] <[email protected]> * dark mode starting * feat(companion): add dark mode support to core infrastructure (Phase 1) * feat(companion): add dark mode support to tab pages (Phase 2) * fix(companion): add dark mode support to booking list components for font visibility * fix(companion): add dark mode support to EventTypeListItem components * event types and bookings page * Availability list page * more page, and some ui fixes * feat(companion): add dark mode support to detail screens and modals - BookingDetailScreen (iOS & Android): dynamic colors for all UI elements - AvailabilityDetailScreen (iOS & Android): dark mode backgrounds and text - profile-sheet (iOS & Android): dark mode support for profile modal - RescheduleScreen (all platforms): dark mode for date/time pickers - EditLocationScreen (iOS & Android): dark mode for location editor - AddGuestsScreen: partial dark mode implementation (in progress) Uses iOS system colors: #000000 (black), #1C1C1E (secondary), #FFFFFF (white), #38383A (border), #8E8E93 (text secondary) * fix typecheck and lint * feat(companion): add dark mode support to remaining screens - ScreenWrapper: Fix hardcoded #800020 color with dynamic destructive color - EditAvailabilityDayScreen: Add dark mode for Android/Web and iOS - EditAvailabilityHoursScreen: Add dark mode for Android/Web and iOS - EditAvailabilityNameScreen: Add dark mode for Android/Web and iOS - EditAvailabilityOverrideScreen: Add dark mode for Web, iOS, and Android - MarkNoShowScreen: Add dark mode with dynamic colors for no-show states - MeetingSessionDetailsScreen: Add dark mode for session details - ViewRecordingsScreen: Add dark mode for recordings list Uses iOS system colors (#000000, #1C1C1E, #38383A, #8E8E93) and useColorScheme hook for automatic theme detection. * fix(companion): fix iOS modal dark mode backgrounds for transparent glass UI * fix(companion): fix dark mode for booking action screens in transparent background mode * fix(companion): fix Android dark mode for transparent background in EditAvailabilityOverrideScreen * refactor(companion): centralize dark mode colors and align with main website - Update tailwind.config.js with centralized color definitions - Create constants/colors.ts for inline style color references - Replace iOS system colors with main website neutral grays: - #1C1C1E -> #171717 (secondary dark background) - #38383A -> #4D4D4D (dark border) - #8E8E93 -> #A3A3A3 (secondary text dark) - Keep pure black (#000000) for main backgrounds (OLED friendly) - Add semantic color mappings for consistent UI styling * feat(companion): complete dark mode implementation across all priority files - Priority 1: Updated SettingsUI.tsx, BasicsTab.tsx, LimitsTab.tsx, AdvancedTab.tsx, RecurringTab.tsx, AvailabilityTab.tsx, event-type-detail.tsx - Priority 2: Updated edit-availability-hours/name/override/day.tsx, oauth/callback.tsx - Priority 3: Updated AvailabilityListScreen.tsx with dark mode for modals and list items - Priority 4: Updated BookingActionsModal.tsx, BookingActionsModal.ios.tsx, GlassModalHeader.tsx, LoginScreen.tsx, LocationsList.tsx, SearchHeader.tsx, toast.tsx Dark mode colors used: - Main bg: #000000 (cal.bg.dark) - Secondary bg: #171717 (cal.bg.secondaryDark) - Muted bg: #262626 (cal.bg.mutedDark) - Borders: #4D4D4D (cal.border.dark) - Secondary text: #A3A3A3 (cal.text.secondaryDark) * fix(companion): add dark mode support to iOS header, GlassView modals, and main content background * fix(companion): add dark mode support to toggle/switch components * fix(companion): use iOS green (#34C759) for toggle ON state in dark mode * fix(companion): use white track + black thumb for dark mode toggles * fix(companion): add dark mode support to login page logo and button * dark mode to many pages, centralize the colors * dark mode skelaton * Availability pages android * toggles * headers * red color for dark mode * copy button * white flash on bottom ui native ios sheet * bottom sheet buttons * bottom sheet in gray color * bottom sheet again.. yeahh * toggle green * toggle green * fix lint and typechecks * fix exntension error * Update companion/components/event-type-detail/tabs/LimitsTabDatePicker.android.tsx Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * address cubic comments --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
867 lines
30 KiB
TypeScript
867 lines
30 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
||
import { useRouter } from "expo-router";
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
RefreshControl,
|
||
ScrollView,
|
||
Text,
|
||
useColorScheme,
|
||
View,
|
||
} from "react-native";
|
||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||
import { AppPressable } from "@/components/AppPressable";
|
||
import * as Clipboard from "expo-clipboard";
|
||
import { showSuccessAlert } from "@/utils/alerts";
|
||
import { useCancelBooking } from "@/hooks/useBookings";
|
||
import type { Booking } from "@/services/calcom";
|
||
import { showErrorAlert } from "@/utils/alerts";
|
||
|
||
const CopyButton = ({
|
||
text,
|
||
color,
|
||
size = 16,
|
||
}: {
|
||
text: string;
|
||
color?: string;
|
||
size?: number;
|
||
}) => (
|
||
<AppPressable
|
||
onPress={async () => {
|
||
if (!text) return;
|
||
await Clipboard.setStringAsync(text);
|
||
showSuccessAlert("Copied", "Copied to clipboard");
|
||
}}
|
||
hitSlop={8}
|
||
className="ml-2 justify-center"
|
||
>
|
||
<Ionicons name="copy-outline" size={size} color={color || "#A3A3A3"} />
|
||
</AppPressable>
|
||
);
|
||
|
||
// Format date for iOS Calendar style: "Thursday, 25 Dec 2025"
|
||
const formatDateCalendarStyle = (dateString: string): string => {
|
||
if (!dateString) return "";
|
||
const date = new Date(dateString);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
return date.toLocaleDateString("en-US", {
|
||
weekday: "long",
|
||
day: "numeric",
|
||
month: "short",
|
||
year: "numeric",
|
||
});
|
||
};
|
||
|
||
// Format time for iOS Calendar style: "11:30 AM - 12 PM"
|
||
const formatTimeCalendarStyle = (startDateString: string, endDateString: string): string => {
|
||
if (!startDateString || !endDateString) return "";
|
||
const startDate = new Date(startDateString);
|
||
const endDate = new Date(endDateString);
|
||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return "";
|
||
|
||
const formatTime = (date: Date): string => {
|
||
const hours = date.getHours();
|
||
const minutes = date.getMinutes();
|
||
const period = hours >= 12 ? "PM" : "AM";
|
||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||
if (minutes === 0) {
|
||
return `${hour12} ${period}`;
|
||
}
|
||
return `${hour12}:${minutes.toString().padStart(2, "0")} ${period}`;
|
||
};
|
||
|
||
return `${formatTime(startDate)} – ${formatTime(endDate)}`;
|
||
};
|
||
|
||
// Format duration for display: "30 min" or "1 hr" or "1 hr 30 min"
|
||
const formatDuration = (minutes: number): string => {
|
||
if (minutes < 60) {
|
||
return `${minutes} min`;
|
||
}
|
||
const hours = Math.floor(minutes / 60);
|
||
const remainingMinutes = minutes % 60;
|
||
if (remainingMinutes === 0) {
|
||
return hours === 1 ? "1 hr" : `${hours} hrs`;
|
||
}
|
||
return `${hours} hr ${remainingMinutes} min`;
|
||
};
|
||
|
||
// Calculate duration from start and end times
|
||
const calculateDuration = (startDateString: string, endDateString: string): number => {
|
||
if (!startDateString || !endDateString) return 0;
|
||
const startDate = new Date(startDateString);
|
||
const endDate = new Date(endDateString);
|
||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return 0;
|
||
return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60));
|
||
};
|
||
|
||
export interface BookingDetailScreenProps {
|
||
/**
|
||
* The booking data to display. When null/undefined, shows loading or error state.
|
||
*/
|
||
booking: Booking | null | undefined;
|
||
/**
|
||
* Whether the booking data is currently being fetched.
|
||
*/
|
||
isLoading: boolean;
|
||
/**
|
||
* Error that occurred while fetching the booking, if any.
|
||
*/
|
||
error: Error | null;
|
||
/**
|
||
* Function to refetch the booking data. Used for pull-to-refresh.
|
||
*/
|
||
refetch: () => void;
|
||
/**
|
||
* Whether a refetch is currently in progress. Used for RefreshControl.
|
||
*/
|
||
isRefetching?: boolean;
|
||
/**
|
||
* Callback to expose internal action handlers to parent component.
|
||
* Used by iOS header menu to trigger actions like reschedule.
|
||
*/
|
||
onActionsReady?: (handlers: {
|
||
openRescheduleModal: () => void;
|
||
openEditLocationModal: () => void;
|
||
openAddGuestsModal: () => void;
|
||
openViewRecordingsModal: () => void;
|
||
openMeetingSessionDetailsModal: () => void;
|
||
openMarkNoShowModal: () => void;
|
||
handleCancelBooking: () => void;
|
||
}) => void;
|
||
}
|
||
|
||
export function BookingDetailScreen({
|
||
booking,
|
||
isLoading,
|
||
error,
|
||
refetch,
|
||
isRefetching = false,
|
||
onActionsReady,
|
||
}: BookingDetailScreenProps): React.JSX.Element {
|
||
const router = useRouter();
|
||
const insets = useSafeAreaInsets();
|
||
const colorScheme = useColorScheme();
|
||
const isDark = colorScheme === "dark";
|
||
|
||
const colors = {
|
||
background: isDark ? "#000000" : "#f2f2f7",
|
||
cardBackground: isDark ? "#171717" : "#FFFFFF",
|
||
text: isDark ? "#FFFFFF" : "#000000",
|
||
textSecondary: isDark ? "#A3A3A3" : "#A3A3A3",
|
||
textTertiary: isDark ? "#636366" : "#636366",
|
||
border: isDark ? "#4D4D4D" : "#E5E5EA",
|
||
destructive: isDark ? "#FF453A" : "#800020",
|
||
badge: isDark ? "#4D4D4D" : "#E5E5EA",
|
||
};
|
||
|
||
const [participantsExpanded, setParticipantsExpanded] = useState(true);
|
||
|
||
// Cancel booking mutation
|
||
const cancelBookingMutation = useCancelBooking();
|
||
const isCancelling = cancelBookingMutation.isPending;
|
||
|
||
const performCancelBooking = useCallback(
|
||
(reason: string) => {
|
||
if (!booking) return;
|
||
|
||
cancelBookingMutation.mutate(
|
||
{ uid: booking.uid, reason },
|
||
{
|
||
onSuccess: () => {
|
||
Alert.alert("Success", "Booking cancelled successfully", [
|
||
{
|
||
text: "OK",
|
||
onPress: () => router.back(),
|
||
},
|
||
]);
|
||
},
|
||
onError: (err) => {
|
||
console.error("Failed to cancel booking");
|
||
if (__DEV__) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
console.debug("[BookingDetailScreen.ios] cancelBooking failed", {
|
||
message,
|
||
});
|
||
}
|
||
showErrorAlert("Error", "Failed to cancel booking. Please try again.");
|
||
},
|
||
}
|
||
);
|
||
},
|
||
[booking, router, cancelBookingMutation]
|
||
);
|
||
|
||
const handleCancelBooking = useCallback(() => {
|
||
if (!booking) return;
|
||
|
||
Alert.alert("Cancel Booking", `Are you sure you want to cancel "${booking.title}"?`, [
|
||
{ text: "No", style: "cancel" },
|
||
{
|
||
text: "Yes, Cancel",
|
||
style: "destructive",
|
||
onPress: () => {
|
||
Alert.prompt(
|
||
"Cancellation Reason",
|
||
"Please provide a reason for cancelling this booking:",
|
||
[
|
||
{ text: "Cancel", style: "cancel" },
|
||
{
|
||
text: "Cancel Booking",
|
||
style: "destructive",
|
||
onPress: (reason?: string) => {
|
||
performCancelBooking(reason?.trim() || "Cancelled by host");
|
||
},
|
||
},
|
||
],
|
||
"plain-text",
|
||
"",
|
||
"default"
|
||
);
|
||
},
|
||
},
|
||
]);
|
||
}, [booking, performCancelBooking]);
|
||
|
||
const openRescheduleModal = useCallback(() => {
|
||
if (!booking) return;
|
||
router.push({
|
||
pathname: "/reschedule",
|
||
params: { uid: booking.uid },
|
||
});
|
||
}, [booking, router]);
|
||
|
||
const openEditLocationModal = useCallback(() => {
|
||
if (!booking) return;
|
||
router.push({
|
||
pathname: "/edit-location",
|
||
params: { uid: booking.uid },
|
||
});
|
||
}, [booking, router]);
|
||
|
||
const openAddGuestsModal = useCallback(() => {
|
||
if (!booking) return;
|
||
router.push({
|
||
pathname: "/add-guests",
|
||
params: { uid: booking.uid },
|
||
});
|
||
}, [booking, router]);
|
||
|
||
const openMarkNoShowModal = useCallback(() => {
|
||
if (!booking) return;
|
||
router.push({
|
||
pathname: "/mark-no-show",
|
||
params: { uid: booking.uid },
|
||
});
|
||
}, [booking, router]);
|
||
|
||
const openViewRecordingsModal = useCallback(() => {
|
||
if (!booking) return;
|
||
router.push({
|
||
pathname: "/view-recordings",
|
||
params: { uid: booking.uid },
|
||
});
|
||
}, [booking, router]);
|
||
|
||
const openMeetingSessionDetailsModal = useCallback(() => {
|
||
if (!booking) return;
|
||
router.push({
|
||
pathname: "/meeting-session-details",
|
||
params: { uid: booking.uid },
|
||
});
|
||
}, [booking, router]);
|
||
|
||
useEffect(() => {
|
||
if (booking && onActionsReady) {
|
||
onActionsReady({
|
||
openRescheduleModal,
|
||
openEditLocationModal,
|
||
openAddGuestsModal,
|
||
openViewRecordingsModal,
|
||
openMeetingSessionDetailsModal,
|
||
openMarkNoShowModal,
|
||
handleCancelBooking,
|
||
});
|
||
}
|
||
}, [
|
||
booking,
|
||
onActionsReady,
|
||
openRescheduleModal,
|
||
openEditLocationModal,
|
||
openAddGuestsModal,
|
||
openViewRecordingsModal,
|
||
openMeetingSessionDetailsModal,
|
||
handleCancelBooking,
|
||
openMarkNoShowModal,
|
||
]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<View
|
||
className="flex-1 items-center justify-center"
|
||
style={{ backgroundColor: colors.background }}
|
||
>
|
||
<ActivityIndicator size="large" color={colors.text} />
|
||
<Text className="mt-4 text-base" style={{ color: colors.textSecondary }}>
|
||
Loading booking...
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (error || !booking) {
|
||
const errorMessage = error?.message || "Booking not found";
|
||
return (
|
||
<View
|
||
className="flex-1 items-center justify-center p-5"
|
||
style={{ backgroundColor: colors.background }}
|
||
>
|
||
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
|
||
<Text className="mb-2 mt-4 text-center text-xl font-bold" style={{ color: colors.text }}>
|
||
{errorMessage}
|
||
</Text>
|
||
<AppPressable
|
||
className="mt-6 rounded-lg px-6 py-3"
|
||
style={{ backgroundColor: isDark ? "#FFFFFF" : "#000000" }}
|
||
onPress={() => router.back()}
|
||
>
|
||
<Text
|
||
className="text-base font-semibold"
|
||
style={{ color: isDark ? "#000000" : "#FFFFFF" }}
|
||
>
|
||
Go Back
|
||
</Text>
|
||
</AppPressable>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const startTime = booking.start || booking.startTime || "";
|
||
const endTime = booking.end || booking.endTime || "";
|
||
const dateFormatted = formatDateCalendarStyle(startTime);
|
||
const timeFormatted = formatTimeCalendarStyle(startTime, endTime);
|
||
const isRecurring =
|
||
booking.recurringEventId || (booking as { recurringBookingUid?: string }).recurringBookingUid;
|
||
|
||
const duration = booking.duration || calculateDuration(startTime, endTime);
|
||
const durationFormatted = duration > 0 ? formatDuration(duration) : null;
|
||
|
||
const eventTypeSlug = booking.eventType?.slug;
|
||
|
||
const hostsCount = booking.hosts?.length || (booking.user ? 1 : 0);
|
||
const attendeesCount = booking.attendees?.length || 0;
|
||
const guestsCount = booking.guests?.length || 0;
|
||
const totalParticipants = hostsCount + attendeesCount + guestsCount;
|
||
|
||
const isPastBooking = new Date(endTime) < new Date();
|
||
|
||
const normalizedStatus = booking.status.toLowerCase();
|
||
|
||
const getAttendeeStatusIcon = (attendee: { noShow?: boolean; absent?: boolean }) => {
|
||
const isNoShow = attendee.noShow || attendee.absent;
|
||
|
||
if (isPastBooking && isNoShow) {
|
||
return {
|
||
name: "close-circle" as const,
|
||
color: "#FF3B30",
|
||
label: "No-show",
|
||
};
|
||
}
|
||
|
||
if (normalizedStatus === "pending") {
|
||
return {
|
||
name: "help-circle" as const,
|
||
color: "#A3A3A3",
|
||
label: "Pending",
|
||
};
|
||
}
|
||
|
||
if (normalizedStatus === "cancelled" || normalizedStatus === "rejected") {
|
||
return {
|
||
name: "close-circle-outline" as const,
|
||
color: "#A3A3A3",
|
||
label: null,
|
||
};
|
||
}
|
||
|
||
return { name: "checkmark-circle" as const, color: "#34C759", label: null };
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<ScrollView
|
||
contentInsetAdjustmentBehavior="automatic"
|
||
contentContainerStyle={{
|
||
paddingHorizontal: 16,
|
||
paddingBottom: insets.bottom + 100,
|
||
}}
|
||
showsVerticalScrollIndicator={false}
|
||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
||
>
|
||
{/* Title Section - iOS Calendar Style */}
|
||
<View className="mb-8">
|
||
{/* Meeting Title */}
|
||
{/* Meeting Title */}
|
||
<Text
|
||
className="mb-4 text-[26px] font-semibold leading-tight"
|
||
style={{ letterSpacing: -0.3, color: colors.text }}
|
||
selectable
|
||
>
|
||
{booking.title}
|
||
</Text>
|
||
|
||
{/* Event Type Slug and Duration Badge */}
|
||
{(eventTypeSlug || durationFormatted) && (
|
||
<View className="mb-3 flex-row items-center">
|
||
{eventTypeSlug && (
|
||
<Text className="text-[15px]" style={{ color: colors.textSecondary }}>
|
||
{eventTypeSlug}
|
||
</Text>
|
||
)}
|
||
{eventTypeSlug && durationFormatted && (
|
||
<Text className="mx-2 text-[15px]" style={{ color: colors.textSecondary }}>
|
||
•
|
||
</Text>
|
||
)}
|
||
{durationFormatted && (
|
||
<View
|
||
className="rounded-full px-2.5 py-1"
|
||
style={{ backgroundColor: isDark ? "#171717" : colors.badge }}
|
||
>
|
||
<Text
|
||
className="text-[13px] font-medium"
|
||
style={{ color: isDark ? "#FFFFFF" : colors.textTertiary }}
|
||
>
|
||
{durationFormatted}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
)}
|
||
|
||
{/* Date */}
|
||
<Text className="mb-0.5 text-[17px]" style={{ color: colors.text }}>
|
||
{dateFormatted}
|
||
</Text>
|
||
|
||
{/* Time */}
|
||
<Text className="mb-0.5 text-[17px]" style={{ color: colors.text }}>
|
||
{timeFormatted}
|
||
</Text>
|
||
|
||
{/* Recurring indicator */}
|
||
{isRecurring && (
|
||
<Text className="mt-0.5 text-[17px]" style={{ color: colors.destructive }}>
|
||
Repeats weekly
|
||
</Text>
|
||
)}
|
||
</View>
|
||
|
||
{/* Participants Card - iOS Calendar Style (Expandable) */}
|
||
<View
|
||
className="mb-4 overflow-hidden rounded-xl"
|
||
style={{ backgroundColor: colors.cardBackground }}
|
||
>
|
||
<AppPressable
|
||
className="flex-row items-center justify-between px-4 py-3.5"
|
||
onPress={() => setParticipantsExpanded(!participantsExpanded)}
|
||
>
|
||
<Text className="text-[17px]" style={{ color: colors.text }}>
|
||
Participants
|
||
</Text>
|
||
<View className="flex-row items-center">
|
||
<Text className="mr-1 text-[17px]" style={{ color: colors.textSecondary }}>
|
||
{totalParticipants}
|
||
</Text>
|
||
<Ionicons
|
||
name={participantsExpanded ? "chevron-down" : "chevron-forward"}
|
||
size={18}
|
||
color={colors.textSecondary}
|
||
/>
|
||
</View>
|
||
</AppPressable>
|
||
|
||
{/* Participants list - only show when expanded */}
|
||
{participantsExpanded && (
|
||
<View
|
||
className="px-4 py-2.5"
|
||
style={{ borderTopWidth: 1, borderTopColor: colors.border }}
|
||
>
|
||
{/* Hosts */}
|
||
{booking.hosts && booking.hosts.length > 0
|
||
? booking.hosts.map((host, index) => (
|
||
<View
|
||
key={host.email || `host-${index}`}
|
||
className="flex-row items-center py-2"
|
||
style={index > 0 ? { borderTopWidth: 1, borderTopColor: colors.border } : {}}
|
||
>
|
||
<Ionicons name="star" size={18} color="#FFD60A" />
|
||
<Text
|
||
className="ml-2.5 flex-1 text-[15px]"
|
||
style={{ color: colors.text }}
|
||
numberOfLines={1}
|
||
>
|
||
{host.name || host.email || "Host"}
|
||
<Text style={{ color: colors.textSecondary }}> (Organizer)</Text>
|
||
</Text>
|
||
<CopyButton text={host.name || host.email || "Host"} />
|
||
</View>
|
||
))
|
||
: booking.user && (
|
||
<View className="flex-row items-center py-2">
|
||
<Ionicons name="star" size={18} color="#FFD60A" />
|
||
<Text
|
||
className="ml-2.5 flex-1 text-[15px]"
|
||
style={{ color: colors.text }}
|
||
numberOfLines={1}
|
||
>
|
||
{booking.user.name || booking.user.email}
|
||
<Text style={{ color: colors.textSecondary }}> (Organizer)</Text>
|
||
</Text>
|
||
<CopyButton text={booking.user.name || booking.user.email} />
|
||
</View>
|
||
)}
|
||
|
||
{/* Attendees */}
|
||
{booking.attendees?.map((attendee, index) => {
|
||
const statusIcon = getAttendeeStatusIcon(attendee);
|
||
return (
|
||
<View
|
||
key={attendee.email}
|
||
className="flex-row items-center py-2"
|
||
style={
|
||
index > 0 || booking.hosts?.length || booking.user
|
||
? { borderTopWidth: 1, borderTopColor: colors.border }
|
||
: {}
|
||
}
|
||
>
|
||
<Ionicons name={statusIcon.name} size={20} color={statusIcon.color} />
|
||
<Text
|
||
className="ml-2.5 flex-1 text-[15px]"
|
||
style={{ color: colors.text }}
|
||
numberOfLines={1}
|
||
>
|
||
{attendee.name || attendee.email}
|
||
{statusIcon.label ? (
|
||
<Text style={{ color: statusIcon.color }}> ({statusIcon.label})</Text>
|
||
) : null}
|
||
</Text>
|
||
<CopyButton text={attendee.name || attendee.email} />
|
||
</View>
|
||
);
|
||
})}
|
||
|
||
{/* Guests */}
|
||
{booking.guests?.map((guestEmail, index) => (
|
||
<View
|
||
key={guestEmail}
|
||
className="flex-row items-center py-2"
|
||
style={
|
||
index > 0 || booking.attendees?.length || booking.hosts?.length || booking.user
|
||
? { borderTopWidth: 1, borderTopColor: colors.border }
|
||
: {}
|
||
}
|
||
>
|
||
<Ionicons name="person-outline" size={20} color={colors.textSecondary} />
|
||
<Text
|
||
className="ml-2.5 flex-1 text-[15px]"
|
||
style={{ color: colors.textSecondary }}
|
||
numberOfLines={1}
|
||
>
|
||
{guestEmail}
|
||
<Text style={{ color: colors.textSecondary }}> (Guest)</Text>
|
||
</Text>
|
||
<CopyButton text={guestEmail} />
|
||
</View>
|
||
))}
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* Custom Fields Card (if available) */}
|
||
{(() => {
|
||
if (!booking.bookingFieldsResponses) return null;
|
||
|
||
const excludedKeys = [
|
||
"location",
|
||
"guests",
|
||
"rescheduledReason",
|
||
"rescheduleReason",
|
||
"email",
|
||
"name",
|
||
"notes",
|
||
"description",
|
||
"additionalNotes",
|
||
];
|
||
|
||
const displayableEntries = Object.entries(booking.bookingFieldsResponses).filter(
|
||
([key, value]) => {
|
||
if (excludedKeys.includes(key)) return false;
|
||
|
||
if (value === null || value === undefined || value === "") return false;
|
||
|
||
if (Array.isArray(value) && value.length === 0) return false;
|
||
|
||
if (typeof value === "object" && !Array.isArray(value) && value !== null) {
|
||
const obj = value as Record<string, unknown>;
|
||
if ("value" in obj && "optionValue" in obj) return false;
|
||
if (Object.keys(obj).length === 0) return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
);
|
||
|
||
if (displayableEntries.length === 0) return null;
|
||
|
||
return (
|
||
<View
|
||
className="mb-4 overflow-hidden rounded-xl"
|
||
style={{ backgroundColor: colors.cardBackground }}
|
||
>
|
||
<View className="px-4 py-3.5">
|
||
<Text
|
||
className="mb-2.5 text-[13px] font-medium uppercase tracking-wide"
|
||
style={{ color: colors.textSecondary }}
|
||
>
|
||
Booking Details
|
||
</Text>
|
||
{displayableEntries.map(([key, value], index) => {
|
||
let displayValue: string;
|
||
if (typeof value === "string") {
|
||
displayValue = value;
|
||
} else if (Array.isArray(value)) {
|
||
displayValue = value.join(", ");
|
||
} else if (typeof value === "boolean") {
|
||
displayValue = value ? "Yes" : "No";
|
||
} else if (typeof value === "number") {
|
||
displayValue = String(value);
|
||
} else {
|
||
return null;
|
||
}
|
||
|
||
const displayKey = key
|
||
.replace(/([A-Z])/g, " $1")
|
||
.replace(/^./, (str) => str.toUpperCase())
|
||
.trim();
|
||
|
||
return (
|
||
<View
|
||
key={key}
|
||
className="py-2"
|
||
style={index > 0 ? { borderTopWidth: 1, borderTopColor: colors.border } : {}}
|
||
>
|
||
<Text className="mb-0.5 text-[13px]" style={{ color: colors.textSecondary }}>
|
||
{displayKey}
|
||
</Text>
|
||
<View className="flex-row items-center justify-between">
|
||
<Text className="text-[17px] flex-1" style={{ color: colors.text }}>
|
||
{displayValue}
|
||
</Text>
|
||
<CopyButton text={displayValue} />
|
||
</View>
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
);
|
||
})()}
|
||
|
||
{/* Description Card (if available) */}
|
||
{booking.description && (
|
||
<View
|
||
className="mb-4 overflow-hidden rounded-xl"
|
||
style={{ backgroundColor: colors.cardBackground }}
|
||
>
|
||
<View className="px-4 py-3.5">
|
||
<Text
|
||
className="mb-1.5 text-[13px] font-medium uppercase tracking-wide"
|
||
style={{ color: colors.textSecondary }}
|
||
>
|
||
Notes
|
||
</Text>
|
||
<View className="flex-row items-start justify-between">
|
||
<Text className="text-[17px] leading-6 flex-1 mr-2" style={{ color: colors.text }}>
|
||
{booking.description}
|
||
</Text>
|
||
<CopyButton text={booking.description} />
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* Rescheduling Info Card (if applicable) */}
|
||
{(booking.rescheduledFromUid ||
|
||
booking.rescheduledToUid ||
|
||
booking.reschedulingReason ||
|
||
booking.fromReschedule) && (
|
||
<View
|
||
className="mb-4 overflow-hidden rounded-xl"
|
||
style={{ backgroundColor: colors.cardBackground }}
|
||
>
|
||
<View className="px-4 py-3.5">
|
||
<Text
|
||
className="mb-2.5 text-[13px] font-medium uppercase tracking-wide"
|
||
style={{ color: colors.textSecondary }}
|
||
>
|
||
Rescheduling Info
|
||
</Text>
|
||
|
||
{(booking.rescheduledFromUid || booking.fromReschedule) && (
|
||
<AppPressable
|
||
className="flex-row items-center justify-between py-2"
|
||
onPress={() => {
|
||
const fromUid = booking.rescheduledFromUid || booking.fromReschedule;
|
||
if (fromUid) {
|
||
router.push({
|
||
pathname: "/(tabs)/(bookings)/booking-detail",
|
||
params: { uid: fromUid },
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<Text className="text-[17px]" style={{ color: colors.text }}>
|
||
Rescheduled from
|
||
</Text>
|
||
<View className="flex-row items-center">
|
||
<Text className="mr-1 text-[15px] text-[#007AFF]">View original</Text>
|
||
<Ionicons name="chevron-forward" size={16} color="#007AFF" />
|
||
</View>
|
||
</AppPressable>
|
||
)}
|
||
|
||
{booking.rescheduledToUid && (
|
||
<AppPressable
|
||
className="flex-row items-center justify-between py-2"
|
||
style={{ borderTopWidth: 1, borderTopColor: colors.border }}
|
||
onPress={() => {
|
||
const uid = booking.rescheduledToUid;
|
||
if (uid) {
|
||
router.push({
|
||
pathname: "/(tabs)/(bookings)/booking-detail",
|
||
params: { uid },
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<Text className="text-[17px]" style={{ color: colors.text }}>
|
||
Rescheduled to
|
||
</Text>
|
||
<View className="flex-row items-center">
|
||
<Text className="mr-1 text-[15px] text-[#007AFF]">View new</Text>
|
||
<Ionicons name="chevron-forward" size={16} color="#007AFF" />
|
||
</View>
|
||
</AppPressable>
|
||
)}
|
||
|
||
{booking.reschedulingReason && (
|
||
<View
|
||
className="py-2"
|
||
style={
|
||
booking.rescheduledFromUid || booking.rescheduledToUid || booking.fromReschedule
|
||
? { borderTopWidth: 1, borderTopColor: colors.border }
|
||
: {}
|
||
}
|
||
>
|
||
<Text className="mb-0.5 text-[13px]" style={{ color: colors.textSecondary }}>
|
||
Reason
|
||
</Text>
|
||
<View className="flex-row items-start justify-between">
|
||
<Text className="text-[17px] flex-1" style={{ color: colors.text }}>
|
||
{booking.reschedulingReason}
|
||
</Text>
|
||
<CopyButton text={booking.reschedulingReason} />
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* Cancellation Info Card (if cancelled) */}
|
||
{normalizedStatus === "cancelled" &&
|
||
(booking.cancellationReason || booking.cancelledByEmail || booking.absentHost) && (
|
||
<View
|
||
className="mb-4 overflow-hidden rounded-xl"
|
||
style={{ backgroundColor: colors.cardBackground }}
|
||
>
|
||
<View className="px-4 py-3.5">
|
||
<Text className="mb-2.5 text-[13px] font-medium uppercase tracking-wide text-[#FF3B30]">
|
||
Cancellation Details
|
||
</Text>
|
||
|
||
{booking.cancellationReason && (
|
||
<View className="py-2">
|
||
<Text className="mb-0.5 text-[13px]" style={{ color: colors.textSecondary }}>
|
||
Reason
|
||
</Text>
|
||
<View className="flex-row items-start justify-between">
|
||
<Text className="text-[17px] flex-1" style={{ color: colors.text }}>
|
||
{booking.cancellationReason}
|
||
</Text>
|
||
<CopyButton text={booking.cancellationReason} />
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{booking.cancelledByEmail && (
|
||
<View
|
||
className="py-2"
|
||
style={
|
||
booking.cancellationReason
|
||
? { borderTopWidth: 1, borderTopColor: colors.border }
|
||
: {}
|
||
}
|
||
>
|
||
<Text className="mb-0.5 text-[13px]" style={{ color: colors.textSecondary }}>
|
||
Cancelled by
|
||
</Text>
|
||
<View className="flex-row items-center justify-between">
|
||
<Text className="text-[17px] flex-1" style={{ color: colors.text }}>
|
||
{booking.cancelledByEmail}
|
||
</Text>
|
||
<CopyButton text={booking.cancelledByEmail} />
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{booking.absentHost && (
|
||
<View
|
||
className="flex-row items-center py-2"
|
||
style={
|
||
booking.cancellationReason || booking.cancelledByEmail
|
||
? { borderTopWidth: 1, borderTopColor: colors.border }
|
||
: {}
|
||
}
|
||
>
|
||
<Ionicons name="warning" size={18} color="#FF9500" />
|
||
<Text className="ml-2 text-[17px]" style={{ color: colors.text }}>
|
||
Host was absent
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
)}
|
||
</ScrollView>
|
||
|
||
{/* Cancelling overlay */}
|
||
{isCancelling && (
|
||
<View className="absolute inset-0 items-center justify-center bg-black/50">
|
||
<View
|
||
className="rounded-2xl px-8 py-6"
|
||
style={{ backgroundColor: colors.cardBackground }}
|
||
>
|
||
<ActivityIndicator size="large" color={colors.text} />
|
||
<Text className="mt-3 text-base font-medium" style={{ color: colors.textSecondary }}>
|
||
Cancelling booking...
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</>
|
||
);
|
||
}
|