diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 1f3bbeb..2be9e1f 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1,6 +1,13 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Text } from 'react-native'; -import { NavigationContainer } from '@react-navigation/native'; +import { + NavigationContainer, + LinkingOptions, + getStateFromPath, + NavigationState, + PartialState, + Route, +} from '@react-navigation/native'; import { navigationRef } from './navigationRef'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -48,12 +55,167 @@ import ApiKeyManagementScreen from '../screens/ApiKeyManagementScreen'; import DocumentationPortalScreen from '../screens/DocumentationPortalScreen'; import IntegrationGuidesScreen from '../screens/IntegrationGuidesScreen'; import { colors } from '../utils/constants'; +import { useUserStore } from '../store/userStore'; +import { FeatureId } from '../types/feature'; +import { featureFlagsService } from '../services/featureFlags'; import { RootStackParamList, TabParamList } from './types'; +import type { SubscriptionTier } from '../types/subscription'; const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); +const routeFeatureMap: Partial> = { + CryptoPayment: FeatureId.CRYPTO_INTEGRATION, + Analytics: FeatureId.ADVANCED_ANALYTICS, + Export: FeatureId.EXPORT_DATA, + DeveloperPortal: FeatureId.DEVELOPER_PORTAL, + SandboxDashboard: FeatureId.SANDBOX_ACCESS, + ApiKeyManagement: FeatureId.API_ACCESS, +}; + +const authRequiredRoutes: Set = new Set([ + 'Profile', + 'AdminDashboard', + 'ApiKeyManagement', + 'DeveloperPortal', + 'SandboxDashboard', + 'MerchantOnboarding', + 'AffiliateDashboard', + 'LoyaltyDashboard', + 'CampaignManagement', +]); + +const requiredParamsByRoute: Partial> = { + SubscriptionDetail: ['id'], + CancellationFlow: ['subscriptionId'], + InvoiceDetail: ['id'], + SegmentDetail: ['segmentId'], +}; + +const getActiveRoute = ( + route: Route | undefined +): Route | undefined => { + if (!route || !('state' in route) || !route.state || !Array.isArray(route.state.routes)) { + return route; + } + + const nested = route.state.routes[route.state.index ?? 0] as Route; + return getActiveRoute(nested); +}; + +const hasValidRequiredParams = (route: Route | undefined): boolean => { + if (!route) return false; + const expected = requiredParamsByRoute[route.name as keyof RootStackParamList]; + if (!expected) return true; + + const params = route.params as Record | undefined; + return expected.every((key) => typeof params?.[key] === 'string' && params?.[key]); +}; + +const getStateFromPathSafe = (path: string, options?: any) => { + const state = getStateFromPath(path, options); + if (!state || !state.routes?.length) return undefined; + + const activeRoute = getActiveRoute(state.routes[state.index ?? 0] as Route); + if (!hasValidRequiredParams(activeRoute)) return undefined; + + return state; +}; + +const isRouteAllowed = ( + route: Route | undefined, + isAuthenticated: boolean, + subscriptionTier: SubscriptionTier +): boolean => { + if (!route) return false; + + if (authRequiredRoutes.has(route.name as keyof RootStackParamList) && !isAuthenticated) { + return false; + } + + const featureId = routeFeatureMap[route.name as keyof RootStackParamList]; + if (featureId) { + const feature = featureFlagsService.getFeature(featureId); + if (!feature || !feature.enabled) { + return false; + } + + if (!feature.tierAccess.includes(subscriptionTier)) { + return false; + } + } + + return true; +}; + +const linking: LinkingOptions = { + prefixes: ['subtrackr://', 'https://subtrackr.app'], + config: { + screens: { + HomeTab: { + path: '', + screens: { + Home: 'home', + AddSubscription: 'subscriptions/add', + SubscriptionDetail: 'subscriptions/:id', + CancellationFlow: 'subscriptions/:subscriptionId/cancel', + WalletConnect: 'wallet/connect', + CryptoPayment: 'crypto-payment/:subscriptionId?', + Community: 'community', + Profile: 'profile/:subscriber?', + Analytics: 'analytics', + SlaDashboard: 'sla', + InvoiceList: 'invoices', + InvoiceDetail: 'invoices/:id', + GDPRSettings: 'settings/privacy', + LanguageSettings: 'settings/language', + ErrorDashboard: 'errors', + SegmentManagement: 'segments', + SegmentDetail: 'segments/:segmentId', + Gamification: 'gamification', + FraudDashboard: 'fraud', + GroupManagement: 'groups', + SupportDashboard: 'support', + UsageDashboard: 'usage/:subscriptionId?/:planId?/:name?', + DeveloperPortal: 'developer', + SandboxDashboard: 'sandbox', + ApiKeyManagement: 'api-keys', + DocumentationPortal: 'docs', + IntegrationGuides: 'integration-guides', + }, + }, + AddTab: 'add', + WalletTab: 'wallet', + AnalyticsTab: 'analytics', + RevenueTab: 'revenue', + SettingsTab: { + path: 'settings', + screens: { + Settings: '', + CalendarIntegration: 'calendar', + WebhookSettings: 'webhooks', + AccountingExport: 'accounting', + BatchOperations: 'batch', + AdminDashboard: 'admin', + FraudDashboard: 'fraud', + TaxSettings: 'tax', + SupportDashboard: 'support', + GroupManagement: 'groups', + MerchantOnboarding: 'merchant-onboarding', + AffiliateDashboard: 'affiliate', + LoyaltyDashboard: 'loyalty', + CampaignManagement: 'campaigns', + DeveloperPortal: 'developer', + DocumentationPortal: 'docs', + ApiKeyManagement: 'api-keys', + }, + }, + }, + }, + getStateFromPath: getStateFromPathSafe, +}; + const HomeStack = () => ( @@ -203,11 +365,6 @@ const SettingsStack = () => ( component={LanguageSettingsScreen} options={{ title: 'Language', headerShown: true }} /> - { }; export const AppNavigator = () => { + const user = useUserStore((state) => state.user); + const subscriptionTier = useUserStore((state) => state.subscriptionTier); + + const handleStateChange = useCallback( + (state?: PartialState | undefined) => { + if (!state) return; + const activeRoute = getActiveRoute(state.routes[state.index ?? 0] as Route); + const isAuthenticated = Boolean(user); + if (!isRouteAllowed(activeRoute, isAuthenticated, subscriptionTier)) { + console.warn( + `Blocked navigation to ${activeRoute?.name}. Falling back to HomeTab due to auth/feature gating.` + ); + if (navigationRef.isReady()) { + navigationRef.reset({ index: 0, routes: [{ name: 'HomeTab' }] }); + } + } + }, + [subscriptionTier, user] + ); + return ( - + ); diff --git a/src/navigation/navigationRef.ts b/src/navigation/navigationRef.ts index 3071cbd..ef9027b 100644 --- a/src/navigation/navigationRef.ts +++ b/src/navigation/navigationRef.ts @@ -1,5 +1,32 @@ import { createNavigationContainerRef } from '@react-navigation/native'; -import type { TabParamList } from './types'; +import type { RootStackParamList, TabParamList } from './types'; export const navigationRef = createNavigationContainerRef(); + +export const navigateTab = ( + name: RouteName, + params?: TabParamList[RouteName] +) => { + if (navigationRef.isReady()) { + navigationRef.navigate(name, params); + } +}; + +export const navigateHomeScreen = ( + screen: RouteName, + params?: RootStackParamList[RouteName] +) => { + if (navigationRef.isReady()) { + navigationRef.navigate('HomeTab', { screen, params }); + } +}; + +export const navigateSettingsScreen = ( + screen: RouteName, + params?: RootStackParamList[RouteName] +) => { + if (navigationRef.isReady()) { + navigationRef.navigate('SettingsTab', { screen, params }); + } +}; diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 5d59998..14eee26 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,4 +1,14 @@ -import { NavigatorScreenParams } from '@react-navigation/native'; +import { NavigatorScreenParams, RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +/** + * Navigation types are intentionally explicit to avoid runtime route mismatches. + * + * Migration guide: + * 1. Replace untyped `useNavigation()` with `useAppNavigation<'RouteName'>()`. + * 2. Replace untyped `useRoute()` with `useAppRoute<'RouteName'>()`. + * 3. For external navigation, use the typed `navigationRef` helpers in `navigationRef.ts`. + */ export type RootStackParamList = { Home: undefined; @@ -32,7 +42,7 @@ export type RootStackParamList = { GroupManagement: undefined; TaxSettings: undefined; SupportDashboard: undefined; - UsageDashboard: undefined; + UsageDashboard: { subscriptionId?: string; planId?: string; name?: string } | undefined; DeveloperPortal: undefined; SandboxDashboard: undefined; ApiKeyManagement: undefined; @@ -52,3 +62,21 @@ export type TabParamList = { RevenueTab: undefined; SettingsTab: undefined; }; + +export type RootStackScreenRouteProp = + RouteProp; + +export type RootStackScreenNavigationProp = + NativeStackNavigationProp; + +export type AppTabNavigationProp = + NativeStackNavigationProp; + +export const useAppNavigation = () => + useNavigation>(); + +export const useAppRoute = () => + useRoute>(); + +export const useAppTabNavigation = () => + useNavigation>(); diff --git a/src/screens/CryptoPaymentScreen.tsx b/src/screens/CryptoPaymentScreen.tsx index c790652..f824a7e 100644 --- a/src/screens/CryptoPaymentScreen.tsx +++ b/src/screens/CryptoPaymentScreen.tsx @@ -12,7 +12,7 @@ import { KeyboardAvoidingView, Platform, } from 'react-native'; -import { useNavigation, useRoute } from '@react-navigation/native'; +import { useAppNavigation, useAppRoute } from '../navigation/types'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; @@ -29,9 +29,9 @@ interface RouteParams { } const CryptoPaymentScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { subscriptionId } = (route.params as RouteParams) || {}; + const navigation = useAppNavigation<'CryptoPayment'>(); + const route = useAppRoute<'CryptoPayment'>(); + const { subscriptionId } = route.params ?? {}; // Handle case when no subscriptionId is provided useEffect(() => { diff --git a/src/screens/ImportScreen.tsx b/src/screens/ImportScreen.tsx index c09b3ae..ee6fc5f 100644 --- a/src/screens/ImportScreen.tsx +++ b/src/screens/ImportScreen.tsx @@ -11,7 +11,7 @@ import { FlatList, Modal, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useAppNavigation } from '../navigation/types'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; @@ -35,7 +35,7 @@ import { useSubscriptionStore } from '../store'; const ImportScreen: React.FC = () => { const { subscriptions, addSubscription, updateSubscription } = useSubscriptionStore(); - const navigation = useNavigation(); + const navigation = useAppNavigation<'Import'>(); const [importMode, setImportMode] = useState('upsert'); const [importText, setImportText] = useState(''); diff --git a/src/screens/SegmentDetailScreen.tsx b/src/screens/SegmentDetailScreen.tsx index c1bdd16..8c99191 100644 --- a/src/screens/SegmentDetailScreen.tsx +++ b/src/screens/SegmentDetailScreen.tsx @@ -5,12 +5,12 @@ import { useTheme } from '../theme/useTheme'; import { Button } from '../components/common/Button'; import { SegmentRuleBuilder } from '../components/segments/SegmentRuleBuilder'; import { SegmentRule } from '../types/segment'; -import { useRoute, useNavigation } from '@react-navigation/native'; +import { useAppRoute, useAppNavigation } from '../navigation/types'; export const SegmentDetailScreen: React.FC = () => { const theme = useTheme(); - const route = useRoute(); - const navigation = useNavigation(); + const route = useAppRoute<'SegmentDetail'>(); + const navigation = useAppNavigation<'SegmentDetail'>(); const { segmentId } = route.params; const { segments, addSegment, updateSegment } = useSegmentStore(); diff --git a/src/screens/SegmentManagementScreen.tsx b/src/screens/SegmentManagementScreen.tsx index 3605e98..3f1ff33 100644 --- a/src/screens/SegmentManagementScreen.tsx +++ b/src/screens/SegmentManagementScreen.tsx @@ -8,11 +8,11 @@ import { useTheme } from '../theme/useTheme'; import { Card } from '../components/common/Card'; import { Button } from '../components/common/Button'; import { SegmentOverlapAnalysis } from '../components/segments/SegmentOverlapAnalysis'; -import { useNavigation } from '@react-navigation/native'; +import { useAppNavigation } from '../navigation/types'; export const SegmentManagementScreen: React.FC = () => { const theme = useTheme(); - const navigation = useNavigation(); + const navigation = useAppNavigation<'SegmentManagement'>(); const { segments, deleteSegment } = useSegmentStore(); const { subscriptions } = useSubscriptionStore(); const { user } = useUserStore(); diff --git a/src/screens/UsageDashboard.tsx b/src/screens/UsageDashboard.tsx index 297a816..aa46c85 100644 --- a/src/screens/UsageDashboard.tsx +++ b/src/screens/UsageDashboard.tsx @@ -1,15 +1,15 @@ import React, { useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, SafeAreaView } from 'react-native'; -import { useRoute, useNavigation } from '@react-navigation/native'; +import { useAppRoute, useAppNavigation } from '../navigation/types'; import { colors, spacing, typography, borderRadius, shadows } from '../utils/constants'; import { useUsageStore } from '../store/usageStore'; import { Button } from '../components/common/Button'; import { Ionicons } from '@expo/vector-icons'; const UsageDashboard: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { subscriptionId, planId, name } = route.params || {}; + const route = useAppRoute<'UsageDashboard'>(); + const navigation = useAppNavigation<'UsageDashboard'>(); + const { subscriptionId, planId, name } = route.params ?? {}; const { fetchUsage } = useUsageStore(); useEffect(() => {