Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 188 additions & 8 deletions src/navigation/AppNavigator.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<TabParamList>();
const Stack = createNativeStackNavigator<RootStackParamList>();

const routeFeatureMap: Partial<Record<keyof RootStackParamList, FeatureId>> = {
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<keyof RootStackParamList> = new Set([
'Profile',
'AdminDashboard',
'ApiKeyManagement',
'DeveloperPortal',
'SandboxDashboard',
'MerchantOnboarding',
'AffiliateDashboard',
'LoyaltyDashboard',
'CampaignManagement',
]);

const requiredParamsByRoute: Partial<Record<keyof RootStackParamList, string[]>> = {
SubscriptionDetail: ['id'],
CancellationFlow: ['subscriptionId'],
InvoiceDetail: ['id'],
SegmentDetail: ['segmentId'],
};

const getActiveRoute = (
route: Route<string, object | undefined> | undefined
): Route<string, object | undefined> | 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<string, object | undefined>;
return getActiveRoute(nested);
};

const hasValidRequiredParams = (route: Route<string, object | undefined> | undefined): boolean => {
if (!route) return false;
const expected = requiredParamsByRoute[route.name as keyof RootStackParamList];
if (!expected) return true;

const params = route.params as Record<string, unknown> | 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<string, object | undefined>);
if (!hasValidRequiredParams(activeRoute)) return undefined;

return state;
};

const isRouteAllowed = (
route: Route<string, object | undefined> | 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<TabParamList> = {
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 = () => (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} options={{ headerShown: false }} />
Expand Down Expand Up @@ -203,11 +365,6 @@ const SettingsStack = () => (
component={LanguageSettingsScreen}
options={{ title: 'Language', headerShown: true }}
/>
<Stack.Screen
name="Export"
component={ExportScreen}
options={{ title: 'Export', headerShown: true }}
/>
<Stack.Screen
name="BatchOperations"
component={BatchOperationsScreen}
Expand Down Expand Up @@ -371,8 +528,31 @@ const TabNavigator = () => {
};

export const AppNavigator = () => {
const user = useUserStore((state) => state.user);
const subscriptionTier = useUserStore((state) => state.subscriptionTier);

const handleStateChange = useCallback(
(state?: PartialState<NavigationState> | undefined) => {
if (!state) return;
const activeRoute = getActiveRoute(state.routes[state.index ?? 0] as Route<string, object | undefined>);
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 (
<NavigationContainer ref={navigationRef}>
<NavigationContainer
ref={navigationRef}
linking={linking}
onStateChange={handleStateChange}>
<TabNavigator />
</NavigationContainer>
);
Expand Down
29 changes: 28 additions & 1 deletion src/navigation/navigationRef.ts
Original file line number Diff line number Diff line change
@@ -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<TabParamList>();

export const navigateTab = <RouteName extends keyof TabParamList>(
name: RouteName,
params?: TabParamList[RouteName]
) => {
if (navigationRef.isReady()) {
navigationRef.navigate(name, params);
}
};

export const navigateHomeScreen = <RouteName extends keyof RootStackParamList>(
screen: RouteName,
params?: RootStackParamList[RouteName]
) => {
if (navigationRef.isReady()) {
navigationRef.navigate('HomeTab', { screen, params });
}
};

export const navigateSettingsScreen = <RouteName extends keyof RootStackParamList>(
screen: RouteName,
params?: RootStackParamList[RouteName]
) => {
if (navigationRef.isReady()) {
navigationRef.navigate('SettingsTab', { screen, params });
}
};
32 changes: 30 additions & 2 deletions src/navigation/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -52,3 +62,21 @@ export type TabParamList = {
RevenueTab: undefined;
SettingsTab: undefined;
};

export type RootStackScreenRouteProp<RouteName extends keyof RootStackParamList> =
RouteProp<RootStackParamList, RouteName>;

export type RootStackScreenNavigationProp<RouteName extends keyof RootStackParamList> =
NativeStackNavigationProp<RootStackParamList, RouteName>;

export type AppTabNavigationProp<RouteName extends keyof TabParamList> =
NativeStackNavigationProp<TabParamList, RouteName>;

export const useAppNavigation = <RouteName extends keyof RootStackParamList>() =>
useNavigation<RootStackScreenNavigationProp<RouteName>>();

export const useAppRoute = <RouteName extends keyof RootStackParamList>() =>
useRoute<RootStackScreenRouteProp<RouteName>>();

export const useAppTabNavigation = <RouteName extends keyof TabParamList>() =>
useNavigation<AppTabNavigationProp<RouteName>>();
8 changes: 4 additions & 4 deletions src/screens/CryptoPaymentScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(() => {
Expand Down
4 changes: 2 additions & 2 deletions src/screens/ImportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,7 +35,7 @@ import { useSubscriptionStore } from '../store';

const ImportScreen: React.FC = () => {
const { subscriptions, addSubscription, updateSubscription } = useSubscriptionStore();
const navigation = useNavigation<any>();
const navigation = useAppNavigation<'Import'>();

const [importMode, setImportMode] = useState<ImportMode>('upsert');
const [importText, setImportText] = useState('');
Expand Down
6 changes: 3 additions & 3 deletions src/screens/SegmentDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>();
const navigation = useNavigation();
const route = useAppRoute<'SegmentDetail'>();
const navigation = useAppNavigation<'SegmentDetail'>();
const { segmentId } = route.params;
const { segments, addSegment, updateSegment } = useSegmentStore();

Expand Down
4 changes: 2 additions & 2 deletions src/screens/SegmentManagementScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>();
const navigation = useAppNavigation<'SegmentManagement'>();
const { segments, deleteSegment } = useSegmentStore();
const { subscriptions } = useSubscriptionStore();
const { user } = useUserStore();
Expand Down
8 changes: 4 additions & 4 deletions src/screens/UsageDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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<any>();
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(() => {
Expand Down