Files
Dhairyashil ShindeandGitHub fa53f54815 feat(companion): add configurable landing page (#27267)
* feat(companion): add configurable landing page feature

- Add useUserPreferences hook for persistent storage of landing page preference
- Add LandingPagePicker component for both iOS and Android/web platforms
- Update tabs index to redirect based on user preference
- Update bookings index to accept initial filter from URL params
- Add App Settings section in More screen with landing page selector
- Clear user preferences on logout for fresh state

* fix(companion): remove try-finally for React Compiler compatibility

* fix(companion): use router.replace for dynamic landing page redirect

Replace Redirect component with router.replace() to fix TypeScript
strict typing issue with expo-router's Href type for dynamic routes.

* fix(companion): use literal route strings for TypeScript strict typing

Use switch statement with literal route strings instead of dynamic
string variable to satisfy expo-router's strict Href type checking.

* working fix

* better css
2026-02-13 11:28:42 -03:00

363 lines
12 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import {
Alert,
Pressable,
ScrollView,
Text,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { LandingPagePicker } from "@/components/LandingPagePicker";
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
import { useAuth } from "@/contexts/AuthContext";
import { useQueryContext } from "@/contexts/QueryContext";
import { useUserProfile } from "@/hooks";
import { type LandingPage, useUserPreferences } from "@/hooks/useUserPreferences";
import { showErrorAlert, showSuccessAlert, showNotAvailableAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { getColors } from "@/constants/colors";
interface MoreMenuItem {
name: string;
icon: keyof typeof Ionicons.glyphMap;
isExternal?: boolean;
href?: string;
onPress?: () => void;
}
export default function More() {
const router = useRouter();
const { logout } = useAuth();
const { clearCache } = useQueryContext();
const { preferences, setLandingPage, landingPageLabel } = useUserPreferences();
const [showLogoutModal, setShowLogoutModal] = useState(false);
const [showLandingPagePicker, setShowLandingPagePicker] = useState(false);
const { data: userProfile } = useUserProfile();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const colors = {
background: isDark ? "#000000" : "#FFFFFF",
backgroundSecondary: isDark ? "#171717" : "#FFFFFF",
backgroundActive: isDark ? "#2C2C2E" : "#F8F9FA",
text: isDark ? "#FFFFFF" : "#333333",
textSecondary: isDark ? "#A3A3A3" : "#C7C7CC",
border: isDark ? "#4D4D4D" : "#E5E5EA",
};
const handleLandingPageSelect = async (value: LandingPage) => {
try {
await setLandingPage(value);
showSuccessAlert("Saved", "First page updated");
} catch {
showErrorAlert("Error", "Failed to save preference. Please try again.");
}
};
const performLogout = async () => {
try {
// Clear in-memory cache before logout
await clearCache();
await logout();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("Logout error", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[More] logout failed", { message, stack });
}
showErrorAlert("Error", "Failed to sign out. Please try again.");
}
};
const handleSignOut = () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: performLogout,
},
]);
};
// handleNotAvailable replaced by global showNotAvailableAlert
const menuItems: MoreMenuItem[] = [
{
name: "Apps",
icon: "grid-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
{
name: "Routing",
icon: "git-branch-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
{
name: "Workflows",
icon: "flash-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
{
name: "Insights",
icon: "bar-chart-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
{
name: "Support",
icon: "help-circle-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
];
return (
<>
{/* iOS Native Header with Glass UI */}
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : isDark ? "dark" : "light"}
>
<Stack.Header.Title large>More</Stack.Header.Title>
<Stack.Header.Right>
{userProfile?.avatarUrl ? (
<Stack.Header.View>
<Pressable onPress={() => router.push("/profile-sheet")}>
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
</Pressable>
</Stack.Header.View>
) : (
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
)}
</Stack.Header.Right>
</Stack.Header>
{/* Content */}
<ScrollView
style={{ backgroundColor: colors.background }}
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
<View
style={{
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.name}
onPress={item.onPress}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 20,
borderBottomWidth: index < menuItems.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
}}
activeOpacity={0.7}
>
<View className="flex-1 flex-row items-center">
<Ionicons name={item.icon} size={20} color={colors.text} />
<Text style={{ color: colors.text }} className="ml-3 text-base font-semibold">
{item.name}
</Text>
</View>
{item.isExternal ? (
<Ionicons name="open-outline" size={20} color={colors.textSecondary} />
) : (
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
)}
</TouchableOpacity>
))}
</View>
{/* App Settings */}
<View
style={{
marginTop: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
<View
style={{
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: isDark ? "#2C2C2E" : "#F9FAFB",
paddingHorizontal: 16,
paddingVertical: 8,
}}
>
<Text
style={{ color: isDark ? "#A3A3A3" : "#6B7280" }}
className="text-xs font-semibold uppercase"
>
App Settings
</Text>
</View>
<TouchableOpacity
onPress={() => setShowLandingPagePicker(true)}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 16,
}}
activeOpacity={0.7}
>
<View className="flex-row items-center">
<Ionicons name="home-outline" size={20} color={colors.text} />
<Text style={{ color: colors.text }} className="ml-3 text-base font-semibold">
First Page
</Text>
</View>
<View className="flex-row items-center">
<View
style={{
marginLeft: 12,
marginRight: 8,
borderRadius: 6,
backgroundColor: isDark ? "#3A3A3C" : "#F3F4F6",
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<Text
style={{ color: isDark ? "#A3A3A3" : "#4B5563" }}
className="text-sm font-medium"
>
{landingPageLabel}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
</View>
{/* Delete Account Link */}
<View
style={{
marginTop: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
<TouchableOpacity
onPress={() =>
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Delete Account")
}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 16,
}}
activeOpacity={0.7}
>
<View className="flex-1 flex-row items-center">
<Ionicons name="trash-outline" size={20} color={theme.destructive} />
<Text className="ml-3 text-base font-medium" style={{ color: theme.destructive }}>
Delete Account
</Text>
</View>
<Ionicons name="open-outline" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<View
style={{
marginTop: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
<TouchableOpacity
onPress={handleSignOut}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 16,
}}
activeOpacity={0.7}
>
<Ionicons name="log-out-outline" size={20} color={theme.destructive} />
<Text className="ml-2 text-base font-medium" style={{ color: theme.destructive }}>
Sign Out
</Text>
</TouchableOpacity>
</View>
{/* Footer Note */}
<Text
style={{ color: isDark ? "#6B7280" : "#9CA3AF" }}
className="mt-6 px-1 text-center text-xs"
>
The companion app is an extension of the web application.{"\n"}
For advanced features, visit{" "}
<Text style={{ color: isDark ? "#D1D5DB" : "#1F2937" }}>app.cal.com</Text>
</Text>
</ScrollView>
{/* Logout Confirmation Modal - kept for consistency */}
<LogoutConfirmModal
visible={showLogoutModal}
onConfirm={() => {
setShowLogoutModal(false);
performLogout();
}}
onCancel={() => setShowLogoutModal(false)}
/>
{/* Landing Page Picker */}
<LandingPagePicker
visible={showLandingPagePicker}
currentValue={preferences.landingPage}
onSelect={handleLandingPageSelect}
onClose={() => setShowLandingPagePicker(false)}
/>
</>
);
}