Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface AgentEmptyStateProps {
title: string;
@@ -58,7 +58,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 8,
textAlign: 'center',
},
@@ -75,6 +75,6 @@ const styles = StyleSheet.create({
},
buttonText: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface AgentMiniChartProps {
data: number[];
@@ -100,6 +100,6 @@ const styles = StyleSheet.create({
flex: 1,
textAlign: 'center',
fontSize: 10,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -8,7 +8,7 @@ import Animated, {
withDelay,
Easing,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
@@ -102,12 +102,12 @@ const styles = StyleSheet.create({
},
value: {
fontSize: 24,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
label: {
fontSize: 10,
fontWeight: '600',
fontFamily: fontFamily.semibold,
marginTop: 2,
},
});
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface AgentSectionHeaderProps {
title: string;
@@ -46,7 +46,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
subtitle: {
fontSize: 13,
@@ -54,6 +54,6 @@ const styles = StyleSheet.create({
},
action: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -4,7 +4,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface AgentStatCardProps {
title: string;
@@ -158,19 +158,19 @@ const styles = StyleSheet.create({
},
trendText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
body: {
gap: 4,
},
value: {
fontSize: 22,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
title: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
trendDesc: {
fontSize: 10,
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { CollectionEntry } from '../types/agentTypes';
import { formatAgentCurrency, formatTime } from '../utils/agentFormatters';
@@ -95,7 +95,7 @@ const styles = StyleSheet.create({
},
name: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
metaRow: {
flexDirection: 'row',
@@ -110,10 +110,10 @@ const styles = StyleSheet.create({
},
amount: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
mode: {
fontSize: 10,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { CommissionSummary } from '../types/agentTypes';
import { formatAgentCurrency } from '../utils/agentFormatters';
import { AgentProgressRing } from './AgentProgressRing';
@@ -81,12 +81,12 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
amount: {
fontSize: 28,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
divider: {
@@ -104,12 +104,12 @@ const styles = StyleSheet.create({
},
statLabel: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
statValue: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
vDivider: {
width: 1,
@@ -126,6 +126,6 @@ const styles = StyleSheet.create({
},
rateText: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { AgentCustomer } from '../types/agentTypes';
import { formatAgentCurrency, maskPhone } from '../utils/agentFormatters';
@@ -133,11 +133,11 @@ const styles = StyleSheet.create({
},
avatarText: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
name: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
phoneRow: {
@@ -163,7 +163,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
},
divider: {
@@ -180,12 +180,12 @@ const styles = StyleSheet.create({
},
detailLabel: {
fontSize: 11,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
detailValue: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
vDivider: {
width: 1,
+27 -7
View File
@@ -1,4 +1,5 @@
import type { AgentLoanCategory, CollectionMode, NewAccountType, SmsType } from '../types/agentTypes';
import type { CategoryHue } from '../../../theme';
export const COLLECTION_MODES: { id: CollectionMode; label: string; icon: string }[] = [
{ id: 'cash', label: 'Cash', icon: 'cash-outline' },
@@ -12,13 +13,32 @@ export const ACCOUNT_TYPES: { id: NewAccountType; label: string; description: st
{ id: 'Savings', label: 'Savings Account', description: 'Standard savings account with interest', icon: 'wallet-outline' },
];
export const LOAN_CATEGORIES: { id: AgentLoanCategory; label: string; icon: string; color: string }[] = [
{ id: 'Personal', label: 'Personal Loan', icon: 'person-outline', color: '#3B82F6' },
{ id: 'Gold', label: 'Gold Loan', icon: 'diamond-outline', color: '#F59E0B' },
{ id: 'Vehicle', label: 'Vehicle Loan', icon: 'car-outline', color: '#10B981' },
{ id: 'Housing', label: 'Housing Loan', icon: 'home-outline', color: '#6366F1' },
{ id: 'Agriculture', label: 'Agriculture Loan', icon: 'leaf-outline', color: '#22C55E' },
{ id: 'Business', label: 'Business Loan', icon: 'briefcase-outline', color: '#EF4444' },
/**
* The loan types an agent can raise a request for.
*
* Each carries a categorical HUE rather than a hex. These are labels, not
* states — nothing about a housing loan is "worse" than a personal one — so
* they resolve through `theme.colors.category`, which hands back a tone that
* is legible on whichever ramp is live plus a matching tint for the chip.
*
* Seven types, seven distinct hues. Business was a Tailwind red and Agriculture
* a second green; a red that means nothing sits badly next to a red that means
* "declined", and two greens next to each other defeat the point of colouring
* them at all.
*/
export const LOAN_CATEGORIES: {
id: AgentLoanCategory;
label: string;
icon: string;
hue: CategoryHue;
}[] = [
{ id: 'Personal', label: 'Personal Loan', icon: 'person-outline', hue: 'blue' },
{ id: 'Gold', label: 'Gold Loan', icon: 'diamond-outline', hue: 'amber' },
{ id: 'Vehicle', label: 'Vehicle Loan', icon: 'car-outline', hue: 'green' },
{ id: 'Housing', label: 'Housing Loan', icon: 'home-outline', hue: 'indigo' },
{ id: 'Agriculture', label: 'Agriculture Loan', icon: 'leaf-outline', hue: 'teal' },
{ id: 'Business', label: 'Business Loan', icon: 'briefcase-outline', hue: 'violet' },
{ id: 'DDS', label: 'Loan Against DDS Deposit', icon: 'wallet-outline', hue: 'sky' },
];
export const SMS_TYPES: { id: SmsType; label: string; icon: string }[] = [
@@ -6,6 +6,7 @@ import { useAppDispatch } from '../../../app/hooks';
import { agentActions } from '../store/agentSlice';
export interface CustomerFormData {
clientId: string;
name: string;
phone: string;
email: string;
@@ -18,6 +19,7 @@ export interface CustomerFormData {
}
const initialData: CustomerFormData = {
clientId: '',
name: '',
phone: '',
email: '',
@@ -35,6 +37,7 @@ export const useCustomerForm = (existingCustomer?: AgentCustomer) => {
const [data, setData] = useState<CustomerFormData>(
existingCustomer
? {
clientId: existingCustomer.clientId || '',
name: existingCustomer.name,
phone: existingCustomer.phone,
email: existingCustomer.email || '',
@@ -64,6 +67,19 @@ export const useCustomerForm = (existingCustomer?: AgentCustomer) => {
}
}, [errors]);
// Set multiple fields at once (e.g. after a Client ID lookup) and clear
// any errors on the fields being populated.
const prefill = useCallback((fields: Partial<CustomerFormData>) => {
setData((prev) => ({ ...prev, ...fields }));
setErrors((prev) => {
const newErrors = { ...prev };
(Object.keys(fields) as (keyof CustomerFormData)[]).forEach((key) => {
delete newErrors[key];
});
return newErrors;
});
}, []);
const validate = useCallback(() => {
const result = validateCustomerForm(data);
setErrors(result.errors);
@@ -103,6 +119,7 @@ export const useCustomerForm = (existingCustomer?: AgentCustomer) => {
data,
errors,
updateField,
prefill,
submit,
isSubmitting: isAdding || isUpdating,
};
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInRight, FadeOutLeft } from 'react-native-reanimated';
import { Input, Button, DatePicker, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useAccountOpeningForm } from '../hooks/useAccountOpeningForm';
@@ -33,7 +33,7 @@ const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepI
{i < currentStepIndex ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
) : (
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontWeight: '700' }}>{i + 1}</Text>
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontFamily: fontFamily.bold }}>{i + 1}</Text>
)}
</View>
{i < steps.length - 1 && <View style={[styles.stepLine, { backgroundColor: i < currentStepIndex ? theme.colors.primary : theme.colors.cardBorder }]} />}
@@ -48,7 +48,7 @@ const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepI
style={[
styles.stepLabel,
{ color: i <= currentStepIndex ? theme.colors.primary : theme.colors.textMuted },
i === currentStepIndex && { fontWeight: '700' },
i === currentStepIndex && { fontFamily: fontFamily.bold },
]}
>
{s.label}
@@ -231,7 +231,7 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
value={draft.initialDeposit}
onChangeText={v => updateField('initialDeposit', v)}
keyboardType="number-pad"
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 16, fontFamily: fontFamily.bold, color: theme.colors.textPrimary }}>₹</Text>}
/>
{(draft.accountType === 'RD' || draft.accountType === 'DDS') && (
<Input
@@ -240,7 +240,7 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
value={draft.monthlyInstallment}
onChangeText={v => updateField('monthlyInstallment', v)}
keyboardType="number-pad"
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 16, fontFamily: fontFamily.bold, color: theme.colors.textPrimary }}>₹</Text>}
/>
)}
</View>
@@ -295,7 +295,7 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Initial Deposit</Text>
<Text style={[styles.reviewValue, { color: theme.colors.success, fontWeight: '800', fontSize: 16 }]}>
<Text style={[styles.reviewValue, { color: theme.colors.success, fontFamily: fontFamily.bold, fontSize: 16 }]}>
{formatAgentCurrency(Number(draft.initialDeposit))}
</Text>
</View>
@@ -361,7 +361,7 @@ const styles = StyleSheet.create({
},
localHeaderTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
},
// ── Step indicator ─────────────────────────
@@ -401,7 +401,7 @@ const styles = StyleSheet.create({
},
stepLabel: {
fontSize: 10,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textTransform: 'uppercase',
letterSpacing: 0.3,
width: 54,
@@ -418,7 +418,7 @@ const styles = StyleSheet.create({
},
stepTitle: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
stepSubtitle: {
@@ -448,7 +448,7 @@ const styles = StyleSheet.create({
},
typeLabel: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
typeDesc: {
@@ -477,7 +477,7 @@ const styles = StyleSheet.create({
},
sectionDividerText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: 12,
@@ -502,7 +502,7 @@ const styles = StyleSheet.create({
flex: 1,
fontSize: 13,
lineHeight: 18,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
// ── Review card ───────────────────────────
@@ -519,11 +519,11 @@ const styles = StyleSheet.create({
},
reviewLabel: {
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
reviewValue: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textAlign: 'right',
flexShrink: 1,
marginLeft: 12,
@@ -5,12 +5,14 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppSelector } from '../../../app/hooks';
import { useToast } from '../../../hooks/useToast';
import { selectCustomerById } from '../store/agentSelectors';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { useCustomerForm } from '../hooks/useCustomerForm';
import { useLazyGetCustomerByClientIdQuery } from '../services/agentApi';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'AddEditCustomer'>;
@@ -18,7 +20,7 @@ const FormSection = ({ title, icon, children }: { title: string, icon: string, c
const theme = useTheme();
return (
<Animated.View entering={FadeInUp} style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionHeader, { borderBottomColor: theme.colors.cardBorder }]}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
</View>
@@ -39,7 +41,9 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) {
customerId ? selectCustomerById(customerId)(state) : null
);
const { data, errors, updateField, submit, isSubmitting } = useCustomerForm(existingCustomer || undefined);
const { data, errors, updateField, prefill, submit, isSubmitting } = useCustomerForm(existingCustomer || undefined);
const toast = useToast();
const [lookupClient, { isFetching: isLookingUp }] = useLazyGetCustomerByClientIdQuery();
const isEdit = !!existingCustomer;
@@ -50,6 +54,31 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) {
}
};
const handleFetchClient = async () => {
const clientId = data.clientId.trim();
if (!clientId) {
toast('Enter a Client ID first', 'error');
return;
}
try {
const client = await lookupClient(clientId).unwrap();
prefill({
name: client.name,
phone: client.phone,
email: client.email,
address: client.address,
city: client.city,
state: client.state,
pincode: client.pincode,
aadhaarNumber: client.aadhaarNumber,
panNumber: client.panNumber,
});
toast('Details loaded', 'success', `${client.name}'s profile has been auto-filled.`);
} catch {
toast('Client not found', 'error', 'No client matches this Client ID.');
}
};
return (
@@ -57,6 +86,27 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) {
<View style={styles.flexContainer}>
<KeyboardAwareScrollView contentContainerStyle={styles.scrollContent} style={styles.flex}>
<FormSection title="Client ID" icon="card-outline">
<Text style={[styles.helperText, { color: theme.colors.textSecondary }]}>
Have an existing Client ID? Enter it to auto-fill the customer's details.
</Text>
<Input
label="Client ID (Optional)"
placeholder="e.g. CL-100245"
value={data.clientId}
onChangeText={(v) => updateField('clientId', v)}
autoCapitalize="characters"
error={errors.clientId}
/>
<Button
title="Fetch Details"
variant="secondary"
onPress={handleFetchClient}
loading={isLookingUp}
leftAccessory={<Ionicons name="search-outline" size={18} color={theme.colors.textPrimary} />}
/>
</FormSection>
<FormSection title="Personal Information" icon="person-outline">
<Input
label="Full Name"
@@ -73,7 +123,7 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) {
keyboardType="number-pad"
maxLength={10}
error={errors.phone}
leftAccessory={<Text style={{ color: theme.colors.textPrimary, fontWeight: 'bold' }}>+91</Text>}
leftAccessory={<Text style={{ color: theme.colors.textPrimary, fontFamily: fontFamily.bold }}>+91</Text>}
/>
<Input
label="Email Address (Optional)"
@@ -183,7 +233,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
sectionIcon: {
width: 32,
@@ -195,13 +244,17 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
sectionContent: {
padding: 16,
paddingBottom: 16,
gap: 16,
},
helperText: {
fontSize: 13,
lineHeight: 18,
},
row: {
flexDirection: 'row',
},
@@ -7,7 +7,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppSelector, useAppDispatch } from '../../../app/hooks';
import type { AgentTabParamList, AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
import { useAgentDashboard } from '../hooks/useAgentDashboard';
@@ -166,7 +166,7 @@ export default function AgentDashboardScreen({ navigation }: { navigation: Agent
title="Today's Collection"
value={formatAgentCurrency(stats.todayCollection)}
icon="wallet-outline"
gradient={['#2563EB', '#3B82F6']}
gradient={['#2563EB', theme.colors.info]}
onPress={goToCollections}
/>
<View style={{ width: 12 }} />
@@ -175,7 +175,7 @@ export default function AgentDashboardScreen({ navigation }: { navigation: Agent
title="This Week"
value={formatAgentCurrency(stats.weeklyCollection)}
icon="trending-up-outline"
gradient={['#059669', '#10B981']}
gradient={[theme.colors.success, theme.colors.success]}
onPress={goToCollectionHistory}
/>
</View>
@@ -196,7 +196,7 @@ export default function AgentDashboardScreen({ navigation }: { navigation: Agent
/>
</View>
<View style={styles.chartDivider} />
<View style={[styles.chartDivider, { backgroundColor: theme.colors.cardBorder }]} />
<Text style={[styles.chartSubtitle, { color: theme.colors.textMuted }]}>Last 7 Days Trend</Text>
<AgentMiniChart
@@ -253,12 +253,12 @@ const styles = StyleSheet.create({
},
greeting: {
fontSize: 14,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
name: {
fontSize: 24,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
avatarFrame: {
@@ -292,22 +292,21 @@ const styles = StyleSheet.create({
},
chartTitle: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
marginBottom: 4,
},
chartValue: {
fontSize: 22,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
chartDivider: {
height: 1,
backgroundColor: 'rgba(0,0,0,0.05)',
marginVertical: 16,
},
chartSubtitle: {
fontSize: 11,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 12,
},
footerSpacer: {
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ScreenShell, Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppDispatch } from '../../../app/hooks';
import { logoutSession } from '../../auth/store/authThunk';
import { Button } from '../../../components/Button';
@@ -48,7 +48,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 24,
fontWeight: 'bold',
fontFamily: fontFamily.bold,
},
subtitle: {
fontSize: 16,
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeInRight, FadeInLeft } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import LoanRequestListScreen from './LoanRequestListScreen';
@@ -94,7 +94,7 @@ const styles = StyleSheet.create({
},
headerTitle: {
fontSize: 22,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
tabContainer: {
@@ -115,7 +115,7 @@ const styles = StyleSheet.create({
},
tabLabel: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
contentArea: {
flex: 1,
@@ -7,7 +7,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
@@ -25,8 +25,8 @@ export default function AgentProfileScreen({ navigation }: { navigation: Navigat
const handleLogout = () => {
Alert.alert(
'Log Out',
'Are you sure you want to log out of your agent account?',
'Log out of this device?',
'Signing out clears everything this app has stored on this phone, not just your session.',
[
{ text: 'Cancel', style: 'cancel' },
{
@@ -153,11 +153,11 @@ const styles = StyleSheet.create({
},
avatarText: {
fontSize: 32,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
name: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
role: {
@@ -179,11 +179,11 @@ const styles = StyleSheet.create({
},
badgeText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
@@ -214,7 +214,7 @@ const styles = StyleSheet.create({
},
settingsLabel: {
fontSize: 15,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
divider: {
height: 1,
+121 -64
View File
@@ -1,100 +1,157 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { View, Text, StyleSheet, Share } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useToast } from '../../../hooks/useToast';
import { useTheme, fontFamily } from '../../../theme';
import type { AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
type Props = NativeStackScreenProps<AgentDashboardStackParamList, 'AgentRoute'>;
export default function AgentRouteScreen({ navigation }: Props) {
type Stop = {
id: string;
name: string;
area: string;
time: string;
status: 'done' | 'next' | 'pending';
};
const STOPS: Stop[] = [
{ id: '1', name: 'Ramesh Kumar', area: 'MG Road, Shop 14', time: '9:30 AM', status: 'done' },
{ id: '2', name: 'Sunita Devi', area: 'Gandhi Nagar, H-22', time: '10:15 AM', status: 'done' },
{ id: '3', name: 'Imran Shaikh', area: 'Market Lane, 7B', time: '11:00 AM', status: 'next' },
{ id: '4', name: 'Lakshmi Bai', area: 'Station Road, 41', time: '11:45 AM', status: 'pending' },
{ id: '5', name: 'Vikram Patel', area: 'Old Town, 9', time: '12:30 PM', status: 'pending' },
];
export default function AgentRouteScreen(_props: Props) {
const theme = useTheme();
const toast = useToast();
const statusColor = (status: Stop['status']) =>
status === 'done' ? theme.colors.success : status === 'next' ? theme.colors.primary : theme.colors.textMuted;
const handleShare = async () => {
const summary = STOPS.map((stop, index) => `${index + 1}. ${stop.name} — ${stop.area} (${stop.time})`).join('\n');
try {
await Share.share({
title: "Today's collection route",
message: `Today's collection route (${STOPS.length} stops):\n\n${summary}`,
});
} catch {
toast('Unable to share', 'error', 'Could not open the share sheet. Please try again.');
}
};
return (
<ScreenShell title="My Route" scrollable={false}>
<View style={styles.container}>
<View style={styles.mapPlaceholder}>
<Ionicons name="map-outline" size={64} color={theme.colors.textMuted} />
<Text style={[styles.mapText, { color: theme.colors.textMuted }]}>Map Integration Coming Soon</Text>
<ScreenShell title="My Route" subtitle="Today's collection route" contentContainerStyle={styles.content}>
{/* Map preview */}
<View style={[styles.mapPlaceholder, { borderColor: theme.colors.cardBorder, backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="map-outline" size={48} color={theme.colors.primary} />
<Text style={[styles.mapText, { color: theme.colors.textSecondary }]}>Route overview · 5 stops · 8.4 km</Text>
<View style={styles.pinRow}>
{STOPS.map((stop) => (
<View key={stop.id} style={[styles.pin, { backgroundColor: statusColor(stop.status) }]} />
))}
</View>
</View>
<View style={[styles.infoCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.infoTitle, { color: theme.colors.textPrimary }]}>Route Summary</Text>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Total Stops</Text>
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>12</Text>
</View>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Completed</Text>
<Text style={[styles.statValue, { color: theme.colors.success }]}>5</Text>
</View>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Remaining</Text>
<Text style={[styles.statValue, { color: theme.colors.warning }]}>7</Text>
</View>
{/* Summary */}
<View style={[styles.infoCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.statCol}>
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>12</Text>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Total stops</Text>
</View>
<View style={[styles.statDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statCol}>
<Text style={[styles.statValue, { color: theme.colors.success }]}>5</Text>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Completed</Text>
</View>
<View style={[styles.statDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statCol}>
<Text style={[styles.statValue, { color: theme.colors.warning }]}>7</Text>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Remaining</Text>
</View>
</View>
<Button
title="Start Navigation"
onPress={() => {}}
style={{ marginTop: 'auto', marginBottom: 24 }}
{/* Stops list */}
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Stops</Text>
<View style={[styles.stopsCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
{STOPS.map((stop, index) => (
<View key={stop.id} style={[styles.stopRow, index !== STOPS.length - 1 && { borderBottomWidth: 1, borderBottomColor: theme.colors.cardBorder }]}>
<View style={[styles.stopDot, { backgroundColor: `${statusColor(stop.status)}20` }]}>
<Ionicons
name={stop.status === 'done' ? 'checkmark' : 'location'}
size={14}
color={statusColor(stop.status)}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.stopName, { color: theme.colors.textPrimary }]}>{stop.name}</Text>
<Text style={[styles.stopArea, { color: theme.colors.textSecondary }]}>{stop.area}</Text>
</View>
<Text style={[styles.stopTime, { color: theme.colors.textMuted }]}>{stop.time}</Text>
</View>
))}
</View>
{/* Actions */}
<View style={styles.actionRow}>
<Button
title="Start Navigation"
fullWidth={false}
style={styles.actionBtn}
onPress={() => toast('Opening maps', 'info', 'Launching turn-by-turn navigation for your route.')}
leftAccessory={<Ionicons name="navigate-outline" size={18} color={theme.colors.white} />}
/>
<Button
title="Share Route"
variant="secondary"
fullWidth={false}
style={styles.actionBtn}
onPress={handleShare}
leftAccessory={<Ionicons name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
/>
</View>
<View style={{ height: 32 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
content: { gap: 16 },
mapPlaceholder: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.02)',
height: 160,
borderRadius: 16,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(0,0,0,0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
mapText: {
marginTop: 16,
fontSize: 16,
fontWeight: '600',
gap: 10,
},
mapText: { fontSize: 14, fontFamily: fontFamily.semibold },
pinRow: { flexDirection: 'row', gap: 8 },
pin: { width: 10, height: 10, borderRadius: 5 },
infoCard: {
flexDirection: 'row',
borderRadius: 16,
borderWidth: 1,
padding: 16,
alignItems: 'center',
},
infoTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 12,
},
divider: {
height: 1,
width: '100%',
marginBottom: 12,
},
statRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
statLabel: {
fontSize: 14,
},
statValue: {
fontSize: 16,
fontWeight: '700',
},
statCol: { flex: 1, alignItems: 'center', gap: 4 },
statDivider: { width: 1, height: 32 },
statValue: { fontSize: 20, fontFamily: fontFamily.bold },
statLabel: { fontSize: 12 },
sectionTitle: { fontSize: 16, fontFamily: fontFamily.bold, marginLeft: 4 },
stopsCard: { borderRadius: 16, borderWidth: 1, paddingHorizontal: 16 },
stopRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
stopDot: { width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center' },
stopName: { fontSize: 14, fontFamily: fontFamily.bold },
stopArea: { fontSize: 12, fontFamily: fontFamily.medium },
stopTime: { fontSize: 12, fontFamily: fontFamily.semibold },
actionRow: { flexDirection: 'row', gap: 12 },
actionBtn: { flex: 1 },
});
@@ -4,7 +4,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { useGetCollectionHistoryQuery } from '../services/agentApi';
@@ -93,12 +93,12 @@ const styles = StyleSheet.create({
},
summaryLabel: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
summaryValue: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
vDivider: {
width: 1,
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, SlideInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { formatFullCurrency, formatDateTime } from '../utils/agentFormatters';
@@ -36,7 +36,7 @@ export default function CollectionReceiptScreen({ route, navigation }: Props) {
styles.infoValue,
{
color: strong ? theme.colors.textPrimary : theme.colors.textSecondary,
fontWeight: strong ? '700' : '500'
fontFamily: strong ? fontFamily.bold : fontFamily.medium
}
]}>
{value}
@@ -130,12 +130,12 @@ const styles = StyleSheet.create({
},
successTitle: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 8,
},
successAmount: {
fontSize: 32,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
dividerContainer: {
flexDirection: 'row',
@@ -199,6 +199,6 @@ const styles = StyleSheet.create({
},
actionBtnText: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -3,7 +3,7 @@ import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
import Animated, { FadeInUp } from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useGetCommissionSummaryQuery } from '../services/agentApi';
@@ -132,13 +132,13 @@ const styles = StyleSheet.create({
totalLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
totalValue: {
color: '#fff',
fontSize: 28,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
iconCircle: {
@@ -161,11 +161,11 @@ const styles = StyleSheet.create({
lastPaidValue: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
historyTitle: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 12,
},
historyCard: {
@@ -181,7 +181,7 @@ const styles = StyleSheet.create({
},
historyMonth: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
statusBadge: {
paddingHorizontal: 8,
@@ -190,7 +190,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
divider: {
height: 1,
@@ -210,6 +210,6 @@ const styles = StyleSheet.create({
},
value: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInRight, FadeOutLeft, FadeIn } from 'react-native-reanimated';
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useLoanRequestForm } from '../hooks/useLoanRequestForm';
@@ -46,7 +46,7 @@ const StepIndicator = ({ step }: { step: number }) => {
<Text style={{
color: isActive ? theme.colors.white : theme.colors.textMuted,
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
}}>{s.id}</Text>
)}
</View>
@@ -54,7 +54,7 @@ const StepIndicator = ({ step }: { step: number }) => {
styles.stepLabel,
{
color: isActive ? theme.colors.primary : isCompleted ? theme.colors.success : theme.colors.textMuted,
fontWeight: isActive ? '700' : '500',
fontFamily: isActive ? fontFamily.bold : fontFamily.medium,
},
]}>{s.label}</Text>
</View>
@@ -103,6 +103,12 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
const [step, setStep] = useState(1);
const selectedCategory = LOAN_CATEGORIES.find(c => c.id === data.category);
// The chosen type's categorical pair — a legible tone and a measured tint —
// for whichever theme is live. Falls back to the brand tint before a type is
// picked, so the banner is never drawn with an undefined colour.
const selectedTone = selectedCategory
? theme.colors.category[selectedCategory.hue]
: { tone: theme.colors.primary, surface: theme.colors.primarySurface };
const handleNext = () => {
if (step === 1 && !data.category) return;
@@ -185,29 +191,40 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
<View style={styles.categoryList}>
{LOAN_CATEGORIES.map(cat => {
const isSelected = data.category === cat.id;
// The hue resolves to a tone and a measured tint here,
// where the theme exists. The tint replaces the old
// `${hex}12` / `${hex}20` alpha suffixes, which composited
// the raw hue over the card and so worked on white only.
const { tone, surface } = theme.colors.category[cat.hue];
return (
<Pressable
key={cat.id}
style={[
styles.catCard,
{
backgroundColor: isSelected ? `${cat.color}12` : theme.colors.surfaceMuted,
borderColor: isSelected ? cat.color : 'transparent',
backgroundColor: isSelected ? surface : theme.colors.surfaceMuted,
borderColor: isSelected ? tone : 'transparent',
borderWidth: isSelected ? 1.5 : 1,
},
]}
onPress={() => updateField('category', cat.id as any)}
>
<View style={[styles.catIcon, { backgroundColor: `${cat.color}20` }]}>
<Ionicons name={cat.icon as any} size={22} color={cat.color} />
<View style={[styles.catIcon, { backgroundColor: surface }]}>
<Ionicons name={cat.icon as any} size={22} color={tone} />
</View>
<Text style={[
styles.catLabel,
{ color: isSelected ? cat.color : theme.colors.textPrimary },
{ color: isSelected ? tone : theme.colors.textPrimary },
]}>{cat.label}</Text>
{isSelected && (
<View style={[styles.catCheck, { backgroundColor: cat.color }]}>
<Ionicons name="checkmark" size={12} color={theme.colors.white} />
<View style={[styles.catCheck, { backgroundColor: tone }]}>
{/* On a saturated fill, so this takes the on-fill
tone — white in light mode, dark ink in dark. */}
<Ionicons
name="checkmark"
size={12}
color={theme.colors.textOnAccentFill}
/>
</View>
)}
</Pressable>
@@ -234,7 +251,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onChangeText={v => updateField('customerPhone', v)}
keyboardType="number-pad"
maxLength={10}
leftAccessory={<Text style={{ color: theme.colors.textMuted, fontWeight: '600', fontSize: 13 }}>+91</Text>}
leftAccessory={<Text style={{ color: theme.colors.textMuted, fontFamily: fontFamily.semibold, fontSize: 13 }}>+91</Text>}
/>
</SectionCard>
</Animated.View>
@@ -244,12 +261,18 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
{step === 2 && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
{selectedCategory && (
<Animated.View entering={FadeIn} style={[styles.selectedBanner, { backgroundColor: `${selectedCategory.color}12`, borderColor: `${selectedCategory.color}30` }]}>
<View style={[styles.bannerIcon, { backgroundColor: `${selectedCategory.color}20` }]}>
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedCategory.color} />
<Animated.View
entering={FadeIn}
style={[
styles.selectedBanner,
{ backgroundColor: selectedTone.surface, borderColor: selectedTone.tone },
]}
>
<View style={[styles.bannerIcon, { backgroundColor: selectedTone.surface }]}>
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedTone.tone} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.bannerTitle, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
<Text style={[styles.bannerTitle, { color: selectedTone.tone }]}>{selectedCategory.label}</Text>
<Text style={[styles.bannerSub, { color: theme.colors.textMuted }]}>for {data.customerName}</Text>
</View>
</Animated.View>
@@ -263,7 +286,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onChangeText={v => updateField('amount', v)}
keyboardType="number-pad"
error={errors.amount}
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '800', color: theme.colors.primary }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 16, fontFamily: fontFamily.bold, color: theme.colors.primary }}>₹</Text>}
/>
<View style={styles.row}>
<View style={styles.col}>
@@ -274,7 +297,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onChangeText={v => updateField('tenureMonths', v)}
keyboardType="number-pad"
error={errors.tenureMonths}
rightAccessory={<Text style={{ fontSize: 12, color: theme.colors.textMuted, fontWeight: '600' }}>months</Text>}
rightAccessory={<Text style={{ fontSize: 12, color: theme.colors.textMuted, fontFamily: fontFamily.semibold }}>months</Text>}
/>
</View>
<View style={{ width: 12 }} />
@@ -286,7 +309,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onChangeText={v => updateField('monthlyIncome', v)}
keyboardType="number-pad"
error={errors.monthlyIncome}
leftAccessory={<Text style={{ fontSize: 14, fontWeight: '700', color: theme.colors.textMuted }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 14, fontFamily: fontFamily.bold, color: theme.colors.textMuted }}>₹</Text>}
/>
</View>
</View>
@@ -311,9 +334,11 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
<View style={[styles.reviewCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
{/* Header badge */}
{selectedCategory && (
<View style={[styles.reviewBadge, { backgroundColor: `${selectedCategory.color}12` }]}>
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedCategory.color} />
<Text style={[styles.reviewBadgeText, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
<View style={[styles.reviewBadge, { backgroundColor: selectedTone.surface }]}>
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedTone.tone} />
<Text style={[styles.reviewBadgeText, { color: selectedTone.tone }]}>
{selectedCategory.label}
</Text>
</View>
)}
@@ -416,7 +441,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 17,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
sectionSubtitle: {
@@ -453,7 +478,7 @@ const styles = StyleSheet.create({
},
catLabel: {
fontSize: 11,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textAlign: 'center',
lineHeight: 14,
},
@@ -486,7 +511,7 @@ const styles = StyleSheet.create({
},
bannerTitle: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
bannerSub: {
fontSize: 12,
@@ -503,7 +528,7 @@ const styles = StyleSheet.create({
errorText: {
fontSize: 12,
marginTop: 6,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
// ── Review Card ───────────────────────────
@@ -524,7 +549,7 @@ const styles = StyleSheet.create({
},
reviewBadgeText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
reviewDivider: {
height: 1,
@@ -538,12 +563,12 @@ const styles = StyleSheet.create({
},
reviewLabel: {
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
minWidth: 80,
},
reviewValue: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textAlign: 'right',
flexShrink: 1,
marginLeft: 16,
@@ -560,7 +585,7 @@ const styles = StyleSheet.create({
},
docsTitle: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 3,
},
docsText: {
@@ -597,7 +622,7 @@ const styles = StyleSheet.create({
},
progressText: {
fontSize: 11,
fontWeight: '600',
fontFamily: fontFamily.semibold,
minWidth: 60,
},
progressTrack: {
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import { useDeleteAgentCustomerMutation } from '../services/agentApi';
@@ -13,6 +13,8 @@ import type { AgentCustomersStackParamList } from '../navigation/agentNavigation
import { formatAgentCurrency, formatDate, maskAadhaar, maskPhone } from '../utils/agentFormatters';
import { AgentSectionHeader } from '../components/AgentSectionHeader';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate, useFeatureAction } from '../../../features';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'CustomerDetail'>;
@@ -55,6 +57,14 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
);
};
// Second line of defence: even if some path reached this handler while
// `agent.customers.delete` is off, it returns before the confirm dialog and
// before any delete request is issued.
const handleDeleteCustomer = useFeatureAction(
FeatureKey.AGENT_CUSTOMERS_DELETE,
handleDelete,
);
const InfoRow = ({ label, value, icon, masked = false }: { label: string, value: string, icon?: string, masked?: boolean }) => (
<View style={styles.infoRow}>
<View style={styles.infoLabelArea}>
@@ -105,12 +115,14 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
<Text style={[styles.iconLabel, { color: theme.colors.success }]}>SMS</Text>
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => navigation.navigate('AddEditCustomer', { customerId: customer.id })}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="create" size={20} color={theme.colors.primary} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
</Pressable>
<FeatureGate feature={FeatureKey.AGENT_CUSTOMERS_EDIT}>
<Pressable style={styles.iconBtn} onPress={() => navigation.navigate('AddEditCustomer', { customerId: customer.id })}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="create" size={20} color={theme.colors.primary} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
</Pressable>
</FeatureGate>
</View>
</Animated.View>
@@ -170,16 +182,18 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
</Animated.View>
{/* Delete Area */}
<View style={styles.deleteArea}>
<Button
title="Remove Customer"
variant="ghost"
loading={isDeleting}
onPress={handleDelete}
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
/>
</View>
<FeatureGate feature={FeatureKey.AGENT_CUSTOMERS_DELETE}>
<View style={styles.deleteArea}>
<Button
title="Remove Customer"
variant="ghost"
loading={isDeleting}
onPress={handleDeleteCustomer}
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
/>
</View>
</FeatureGate>
<View style={styles.footerSpacer} />
</ScrollView>
@@ -209,14 +223,14 @@ const styles = StyleSheet.create({
},
avatarBigText: {
fontSize: 32,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
headerInfo: {
alignItems: 'center',
},
headerName: {
fontSize: 22,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
headerPhone: {
@@ -230,7 +244,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
actionButtonsRow: {
flexDirection: 'row',
@@ -250,7 +264,7 @@ const styles = StyleSheet.create({
},
iconLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
section: {
marginBottom: 24,
@@ -267,11 +281,11 @@ const styles = StyleSheet.create({
},
accType: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
accNumber: {
fontSize: 14,
fontFamily: 'monospace',
fontFamily: fontFamily.mono,
},
divider: {
height: 1,
@@ -292,7 +306,7 @@ const styles = StyleSheet.create({
},
finValue: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
vDivider: {
width: 1,
@@ -314,7 +328,7 @@ const styles = StyleSheet.create({
},
infoValue: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
flex: 1,
textAlign: 'right',
},
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
@@ -73,7 +73,7 @@ export default function CustomerInstallmentHistoryScreen({ route, navigation }:
return (
<ScreenShell title="Installment History" scrollable={false}>
<View style={styles.header}>
<View style={[styles.header, { borderBottomColor: theme.colors.cardBorder }]}>
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
<Text style={[styles.accNumber, { color: theme.colors.textMuted }]}>
{customer.accounts[0]?.type} A/C: {customer.accounts[0]?.accountNumber}
@@ -96,16 +96,15 @@ const styles = StyleSheet.create({
padding: 16,
paddingTop: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
customerName: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
accNumber: {
fontSize: 14,
fontFamily: 'monospace',
fontFamily: fontFamily.mono,
},
listContent: {
padding: 16,
@@ -133,7 +132,7 @@ const styles = StyleSheet.create({
},
date: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
marginBottom: 2,
},
receipt: {
@@ -144,11 +143,11 @@ const styles = StyleSheet.create({
},
amount: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
status: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
@@ -4,10 +4,10 @@ import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
@@ -20,6 +20,8 @@ import type { CustomerFilter } from '../types/agentTypes';
import { CustomerCard } from '../components/CustomerCard';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate } from '../../../features';
type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentCustomersStackParamList, 'CustomerList'>,
@@ -143,8 +145,12 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
onAction={searchQuery ? () => handleSearch('') : undefined}
/>
)}
// No `exiting` here: FlatList unmounts cells for virtualisation as well
// as for deletion, and an exit animation makes Reanimated hold the
// outgoing native view while Fabric is recycling that cell — which
// crashes with "addViewAt: the specified child already has a parent".
renderItem={({ item, index }) => (
<Animated.View entering={FadeIn.delay(Math.min(index * 50, 500))} exiting={FadeOut}>
<Animated.View entering={FadeIn.delay(Math.min(index * 50, 500))}>
<CustomerCard
customer={item}
onPress={() => navigation.navigate('CustomerDetail', { customerId: item.id })}
@@ -156,12 +162,14 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
</View>
{/* FAB to add new customer */}
<Pressable
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
onPress={() => navigation.navigate('AddEditCustomer', {})}
>
<Ionicons name="add" size={24} color={theme.colors.white} />
</Pressable>
<FeatureGate feature={FeatureKey.AGENT_CUSTOMERS_CREATE}>
<Pressable
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
onPress={() => navigation.navigate('AddEditCustomer', {})}
>
<Ionicons name="add" size={24} color={theme.colors.white} />
</Pressable>
</FeatureGate>
</ScreenShell>
);
}
@@ -205,7 +213,7 @@ const styles = StyleSheet.create({
},
filterText: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
listArea: {
flex: 1,
@@ -8,7 +8,7 @@ import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
import { ScreenShell, Button, Input } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
@@ -64,7 +64,7 @@ const CollectionForm = ({ selectedEntry, navigation, bottomSheetRef }: { selecte
onChangeText={setAmount}
keyboardType="number-pad"
error={formError || undefined}
leftAccessory={<Text style={{ fontSize: 18, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 18, fontFamily: fontFamily.bold, color: theme.colors.textPrimary }}>₹</Text>}
/>
<Text style={[styles.modeLabel, { color: theme.colors.textMuted }]}>Payment Mode</Text>
@@ -314,13 +314,13 @@ const styles = StyleSheet.create({
summaryLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
summaryValue: {
color: '#fff',
fontSize: 24,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
progressTrack: {
@@ -346,7 +346,7 @@ const styles = StyleSheet.create({
offlineText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
listArea: {
flex: 1,
@@ -361,11 +361,11 @@ const styles = StyleSheet.create({
},
listTitle: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
historyLink: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
filterRow: {
flexDirection: 'row',
@@ -381,7 +381,7 @@ const styles = StyleSheet.create({
},
filterText: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
listContent: {
padding: 16,
@@ -411,11 +411,11 @@ const styles = StyleSheet.create({
},
avatarText: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
entryName: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
entryMeta: {
@@ -433,11 +433,11 @@ const styles = StyleSheet.create({
collectBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
entryAmount: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
statusBadge: {
paddingHorizontal: 8,
@@ -446,7 +446,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
sheetContainer: {
flex: 1,
@@ -457,7 +457,7 @@ const styles = StyleSheet.create({
},
sheetTitle: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginBottom: 24,
},
sheetCustomerInfo: {
@@ -465,7 +465,7 @@ const styles = StyleSheet.create({
},
sheetCustomerName: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
sheetCustomerMeta: {
@@ -473,7 +473,7 @@ const styles = StyleSheet.create({
},
modeLabel: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
marginBottom: 12,
marginTop: 16,
},
@@ -493,6 +493,6 @@ const styles = StyleSheet.create({
},
modeText: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -4,7 +4,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { SlideInRight } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
@@ -114,7 +114,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
newBtn: {
flexDirection: 'row',
@@ -126,7 +126,7 @@ const styles = StyleSheet.create({
},
newBtnText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
listContent: {
padding: 16,
@@ -148,7 +148,7 @@ const styles = StyleSheet.create({
},
customerName: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
category: {
@@ -161,7 +161,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
divider: {
height: 1,
@@ -178,6 +178,6 @@ const styles = StyleSheet.create({
},
value: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
import { useGetSmsLogsQuery } from '../services/agentApi';
@@ -106,7 +106,7 @@ const styles = StyleSheet.create({
},
phone: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
headerRight: {
flexDirection: 'row',
@@ -115,7 +115,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
divider: {
height: 1,
+24
View File
@@ -2,6 +2,7 @@ import { baseApi } from '../../../services/api/baseApi';
import type {
AgentDashboardStats,
AgentCustomer,
ClientDirectoryRecord,
CollectionEntry,
CommissionSummary,
AgentLoanRequest,
@@ -12,6 +13,7 @@ import type {
import {
MOCK_DASHBOARD_STATS,
MOCK_CUSTOMERS,
MOCK_CLIENT_DIRECTORY,
MOCK_TODAY_COLLECTIONS,
MOCK_COMMISSION_SUMMARY,
MOCK_LOAN_REQUESTS,
@@ -42,11 +44,32 @@ export const agentApi = baseApi.injectEndpoints({
providesTags: ['AgentCustomers'],
}),
// Look up a pre-registered client by Client ID to auto-fill the form.
getCustomerByClientId: builder.query<ClientDirectoryRecord, string>({
queryFn: async (clientId) => {
await delay(500);
const normalized = clientId.trim().toLowerCase();
const match = MOCK_CLIENT_DIRECTORY.find(
(c) => c.clientId.toLowerCase() === normalized,
);
if (!match) {
return {
error: {
status: 404,
message: 'No client found for this Client ID',
},
};
}
return { data: match };
},
}),
addAgentCustomer: builder.mutation<AgentCustomer, Partial<AgentCustomer>>({
queryFn: async (customer) => {
await delay(400);
const newCustomer: AgentCustomer = {
id: `c${Date.now()}`,
clientId: customer.clientId,
name: customer.name ?? '',
phone: customer.phone ?? '',
email: customer.email,
@@ -223,6 +246,7 @@ export const agentApi = baseApi.injectEndpoints({
export const {
useGetAgentDashboardQuery,
useGetAgentCustomersQuery,
useLazyGetCustomerByClientIdQuery,
useAddAgentCustomerMutation,
useUpdateAgentCustomerMutation,
useDeleteAgentCustomerMutation,
+17 -1
View File
@@ -68,6 +68,7 @@ export interface CustomerAccount {
export interface AgentCustomer {
id: string;
clientId?: string;
name: string;
phone: string;
email?: string;
@@ -86,6 +87,21 @@ export interface AgentCustomer {
joinedDate: string;
}
// Pre-registered client record looked up by Client ID so the agent can
// auto-fill a new customer's details instead of re-entering everything.
export interface ClientDirectoryRecord {
clientId: string;
name: string;
phone: string;
email: string;
address: string;
city: string;
state: string;
pincode: string;
aadhaarNumber: string;
panNumber: string;
}
// ─── Collection ──────────────────────────────────────────────────────────────
export type CollectionStatus = 'collected' | 'pending' | 'partial' | 'skipped';
export type CollectionMode = 'cash' | 'upi' | 'bank_transfer';
@@ -161,7 +177,7 @@ export interface CommissionSummary {
// ─── Loan Request ────────────────────────────────────────────────────────────
export type LoanRequestStatus = 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected' | 'disbursed';
export type AgentLoanCategory = 'Personal' | 'Gold' | 'Vehicle' | 'Housing' | 'Agriculture' | 'Business';
export type AgentLoanCategory = 'Personal' | 'Gold' | 'Vehicle' | 'Housing' | 'Agriculture' | 'Business' | 'DDS';
export interface AgentLoanRequest {
id: string;
+43
View File
@@ -2,6 +2,7 @@ import type {
AgentProfile,
AgentDashboardStats,
AgentCustomer,
ClientDirectoryRecord,
CollectionEntry,
CommissionSummary,
CommissionRecord,
@@ -102,6 +103,48 @@ export const MOCK_CUSTOMERS: AgentCustomer[] = [
makeCustomer('c012', 'Anita Patel', '9876543012', 'active', 0, 19000, 500),
];
// Existing clients in the core banking system, keyed by Client ID. When an
// agent enters one of these IDs while adding a customer, the full profile is
// fetched and the form is auto-filled.
export const MOCK_CLIENT_DIRECTORY: ClientDirectoryRecord[] = [
{
clientId: 'CL-100245',
name: 'Rohit Malhotra',
phone: '9876501245',
email: 'rohit.malhotra@email.com',
address: '45, Park Street',
city: 'Kolkata',
state: 'West Bengal',
pincode: '700016',
aadhaarNumber: '432109876543',
panNumber: 'BMNPK4521R',
},
{
clientId: 'CL-100312',
name: 'Sneha Banerjee',
phone: '9831004412',
email: 'sneha.banerjee@email.com',
address: '12B, Salt Lake Sector V',
city: 'Kolkata',
state: 'West Bengal',
pincode: '700091',
aadhaarNumber: '785412369087',
panNumber: 'CDKPB7788L',
},
{
clientId: 'CL-100489',
name: 'Imran Khan',
phone: '9903112278',
email: 'imran.khan@email.com',
address: '7, Lake Town',
city: 'Howrah',
state: 'West Bengal',
pincode: '711101',
aadhaarNumber: '109283746501',
panNumber: 'EFLPK2345M',
},
];
const makeCollectionEntry = (
id: string, customerId: string, customerName: string, phone: string,
emiAmount: number, collected: number, status: 'collected' | 'pending' | 'partial',
+139 -58
View File
@@ -1,81 +1,162 @@
import React, { useEffect } from 'react';
import { StyleSheet, Text, View, Pressable, Platform } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
interpolate
} from 'react-native-reanimated';
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
interface BiometricNodeProps {
onPress: () => void;
/**
* `hero` is the large centred target for the full-bleed navy sign-in screen,
* where biometrics are the main way in. `tile` is the compact row for a light
* surface, where biometrics are the alternative to an MPIN already on screen.
*/
variant?: 'hero' | 'tile';
/**
* Override when the platform tells you which sensor is enrolled — "Use Face
* ID", "Use fingerprint". The default stays generic because naming the wrong
* sensor is worse than not naming one.
*/
label?: string;
/** Only shown by `hero`, beneath the target. */
caption?: string;
disabled?: boolean;
}
export const BiometricNode = ({ onPress }: BiometricNodeProps) => {
/**
* The biometric sign-in affordance.
*
* It used to be a 64pt circle scaling between 1.0 and 1.05 on an infinite
* repeat. That ran a Reanimated loop for as long as the screen was open, drew
* the eye away from the field most customers actually use, and read like a game
* prompt rather than a bank's. Both shapes below are still, with the press
* itself as the only motion.
*/
const BiometricNodeComponent = ({
onPress,
variant = 'tile',
label = 'Use biometrics',
caption = 'Tap to sign in',
disabled = false,
}: BiometricNodeProps) => {
const theme = useTheme();
const scale = useSharedValue(1);
useEffect(() => {
scale.value = withRepeat(
withTiming(1.05, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
-1,
true
);
}, []);
if (variant === 'hero') {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="Signs you in using the fingerprint or face unlock on this device"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={styles.hero}
>
{({ pressed }) => (
<>
<View style={styles.frame}>
{/* Pressing brightens rather than dims. Fading an element on a
navy wash reduces its contrast against the wash, which is the
wrong direction for feedback on a sign-in control. */}
<Ionicons
name="scan-outline"
size={148}
color={pressed ? theme.colors.onBrandPrimary : theme.colors.onBrandSecondary}
/>
<View style={styles.frameCentre} pointerEvents="none">
<Ionicons name="finger-print" size={52} color={theme.colors.onBrandPrimary} />
</View>
</View>
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<View style={styles.container}>
<Pressable onPress={onPress} style={styles.pressable}>
<Animated.View style={[styles.node, { backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder }, animatedStyle]}>
<Ionicons name="finger-print-outline" size={32} color={theme.colors.primary} />
</Animated.View>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Use Biometrics</Text>
{caption ? (
<Text style={[theme.text.body, { color: theme.colors.onBrandSecondary }]}>
{caption}
</Text>
) : null}
</>
)}
</Pressable>
</View>
);
}
/**
* The compact shape. Rendered as a full-width tile rather than a bare icon:
* the icon alone gave no hint of what tapping it would do, and a 56pt row is
* a target a thumb finds without aiming.
*/
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="Signs you in using the fingerprint or face unlock on this device"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.tile,
{
minHeight: theme.layout.controlHeightAgent,
borderRadius: theme.radius.md,
borderColor: pressed ? theme.colors.primary : theme.colors.cardBorderStrong,
backgroundColor: pressed ? theme.colors.primarySurface : theme.colors.surface,
paddingHorizontal: theme.spacing.md,
gap: theme.spacing.sm,
opacity: disabled ? 0.55 : 1,
},
]}
>
<View
style={[
styles.iconPlate,
{
backgroundColor: theme.colors.primarySurface,
borderRadius: theme.radius.sm,
},
]}
>
<Ionicons name="finger-print" size={20} color={theme.colors.primary} />
</View>
<Text style={[theme.text.button, styles.tileLabel, { color: theme.colors.primary }]}>
{label}
</Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.textMuted} />
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
hero: {
alignItems: 'center',
marginTop: 8,
gap: 14,
},
pressable: {
alignItems: 'center',
gap: 10,
},
node: {
width: 64,
height: 64,
borderRadius: 32,
borderWidth: 1,
frame: {
alignItems: 'center',
justifyContent: 'center',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 2,
},
}),
},
label: {
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
// Overlays the fingerprint on the centre of the scan brackets. Absolute
// rather than a nested child because the glyph's own box has padding that
// would otherwise offset it.
frameCentre: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
tile: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
},
iconPlate: {
width: 34,
height: 34,
alignItems: 'center',
justifyContent: 'center',
},
tileLabel: {
flex: 1,
},
});
export const BiometricNode = memo(BiometricNodeComponent);
+186
View File
@@ -0,0 +1,186 @@
import React, { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
interface CodeInputProps {
value: string;
onChangeText: (value: string) => void;
/** How many boxes to draw. An MPIN may be 4 or 6, an OTP is always 6. */
length: number;
label?: string;
error?: string;
/** Masks each digit, for an MPIN rather than an OTP. */
secure?: boolean;
autoFocus?: boolean;
onSubmit?: () => void;
editable?: boolean;
}
/**
* A segmented digit field: one real input, hidden behind boxes that show what
* has been typed. Keeps paste, backspace and the platform's SMS autofill
* working, which a box-per-input implementation loses.
*/
export const CodeInput = ({
value,
onChangeText,
length,
label,
error,
secure = false,
autoFocus = false,
onSubmit,
editable = true,
}: CodeInputProps) => {
const theme = useTheme();
const inputRef = useRef<TextInput>(null);
const [focused, setFocused] = useState(false);
useEffect(() => {
if (autoFocus) {
// A frame's grace, otherwise the keyboard races the screen transition and
// sometimes never appears.
const timer = setTimeout(() => inputRef.current?.focus(), 250);
return () => clearTimeout(timer);
}
}, [autoFocus]);
const handleChange = (next: string) => {
onChangeText(next.replace(/\D/g, '').slice(0, length));
};
const boxes = Array.from({ length }, (_, index) => index);
const activeIndex = Math.min(value.length, length - 1);
return (
<View style={styles.container}>
{label ? (
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>{label}</Text>
) : null}
{/* The hidden input is scoped to this wrapper rather than the whole
component, so it cannot swallow a tap meant for the error text. */}
<View style={styles.field}>
<Pressable
accessibilityRole="none"
onPress={() => inputRef.current?.focus()}
style={styles.boxRow}
>
{boxes.map((index) => {
const filled = index < value.length;
const isActive = focused && index === activeIndex && editable;
return (
<View
key={index}
style={[
styles.box,
{
borderRadius: theme.radius.md,
// A filled box keeps a stronger border than an empty one, so
// progress through the code is legible without relying on
// the dot alone — which matters at arm's length in sunlight.
borderColor: error
? theme.colors.danger
: isActive
? theme.colors.primary
: filled
? theme.colors.cardBorderStrong
: theme.colors.cardBorder,
borderWidth: isActive ? 1.5 : 1,
backgroundColor: error
? theme.colors.dangerSurface
: editable
? theme.colors.surface
: theme.colors.surfaceMuted,
},
]}
>
{filled ? (
secure ? (
<View style={[styles.dot, { backgroundColor: theme.colors.textPrimary }]} />
) : (
<Text style={[theme.text.h2, { color: theme.colors.textPrimary }]}>
{value[index]}
</Text>
)
) : null}
</View>
);
})}
</Pressable>
<TextInput
ref={inputRef}
value={value}
onChangeText={handleChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onSubmitEditing={onSubmit}
keyboardType="number-pad"
editable={editable}
maxLength={length}
autoComplete={secure ? 'off' : 'sms-otp'}
textContentType={secure ? 'none' : 'oneTimeCode'}
caretHidden
// The boxes are decoration as far as a screen reader is concerned;
// this is the field, so it carries the label and the progress.
accessibilityLabel={label ?? (secure ? 'MPIN' : 'One-time password')}
accessibilityValue={{ text: `${value.length} of ${length} digits entered` }}
style={styles.hiddenInput}
/>
</View>
{error ? (
<View style={styles.message}>
<Ionicons name="alert-circle" size={14} color={theme.colors.danger} />
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.danger }]}>
{error}
</Text>
</View>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 8,
},
field: {
position: 'relative',
},
boxRow: {
flexDirection: 'row',
gap: 10,
},
box: {
flex: 1,
aspectRatio: 0.85,
maxHeight: 60,
alignItems: 'center',
justifyContent: 'center',
},
dot: {
width: 12,
height: 12,
borderRadius: 6,
},
// Kept in the tree but invisible: it holds focus and the real value, while
// the boxes above are only a rendering of it.
hiddenInput: {
...StyleSheet.absoluteFillObject,
opacity: 0,
},
message: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
},
messageText: {
flex: 1,
},
});
+141
View File
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { BottomSheet } from '../../../components';
import { useTheme } from '../../../theme';
import { company, copyrightLine, privacy, terms, type LegalSection } from '../../../constants/legalContent';
/**
* The company and legal line at the foot of the sign-in screen.
*
* Two jobs, and they are separate ones. Naming the entity and its regulator is
* a trust signal — on a banking login it is the thing that distinguishes the
* real app from a convincing copy of it, so it is stated plainly rather than
* hidden a tap away. The terms and privacy links are the consent record: this
* is the last screen before a customer signs in, so it is where the agreement
* has to be reachable.
*
* The documents open in a sheet rather than by navigating. Sign-in is
* pre-auth — the legal screen inside the app lives in the profile stack, which
* is not mounted yet — so a link that navigated would either need the route
* duplicated into the auth stack or would simply fail. A sheet also keeps the
* customer on the screen they were signing in on, with whatever they had
* already typed intact.
*/
const LegalBody = ({ sections }: { sections: LegalSection[] }) => {
const theme = useTheme();
// A plain View, not a ScrollView: BottomSheet already wraps its children in
// one, and two same-direction scrollers nested inside each other is the
// documented way to get a list that will not scroll. The sheet is capped at
// 80% of the screen and its own scroller handles a long policy.
return (
<View style={styles.sheetContent}>
{sections.map((section) => (
<View key={section.heading} style={styles.section}>
<Text style={[theme.text.h3, { color: theme.colors.textPrimary }]}>
{section.heading}
</Text>
{section.body.map((paragraph) => (
<Text
key={paragraph}
style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}
>
{paragraph}
</Text>
))}
</View>
))}
<Text style={[theme.text.caption, styles.regulator, { color: theme.colors.textMuted }]}>
{company.regulator}
</Text>
</View>
);
};
export const LoginFooter = () => {
const theme = useTheme();
const [open, setOpen] = useState<'terms' | 'privacy' | null>(null);
return (
<View style={styles.wrapper}>
{/*
One sentence, not two links floating on their own: the agreement is
what signing in means, so the sentence says so and the links sit inside
it. `onBrandMuted` is the quietest on-brand tone that still clears AA
on the wash (4.80:1) — this is fine print, and it should read as fine
print without becoming unreadable.
*/}
<Text style={[theme.text.caption, styles.centre, { color: theme.colors.onBrandMuted }]}>
By signing in you agree to our{' '}
{/* Solid white on the links, so the two tappable spans are separable
from the sentence around them by more than colour alone — they also
carry an underline, which is what survives a colour-blind reading. */}
<Text
accessibilityRole="link"
onPress={() => setOpen('terms')}
style={[styles.link, { color: theme.colors.onBrandPrimary }]}
>
Terms &amp; Conditions
</Text>{' '}
and{' '}
<Text
accessibilityRole="link"
onPress={() => setOpen('privacy')}
style={[styles.link, { color: theme.colors.onBrandPrimary }]}
>
Privacy Policy
</Text>
</Text>
{/* The entity and its regulator. Quietest tier, centred, and deliberately
last: it is a statement of record, not something to act on. */}
<View style={styles.identity}>
<Text style={[theme.text.caption, styles.centre, { color: theme.colors.onBrandMuted }]}>
{copyrightLine()}
</Text>
<Text style={[theme.text.caption, styles.centre, { color: theme.colors.onBrandMuted }]}>
{company.regulator}
</Text>
</View>
<BottomSheet
visible={open !== null}
title={open === 'privacy' ? 'Privacy Policy' : 'Terms & Conditions'}
onClose={() => setOpen(null)}
>
<LegalBody sections={open === 'privacy' ? privacy : terms} />
</BottomSheet>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
gap: 8,
paddingHorizontal: 24,
paddingBottom: 4,
},
centre: {
textAlign: 'center',
},
link: {
textDecorationLine: 'underline',
},
identity: {
gap: 2,
},
sheetContent: {
gap: 18,
paddingBottom: 8,
},
section: {
gap: 6,
},
regulator: {
textAlign: 'center',
paddingTop: 4,
},
});
-106
View File
@@ -1,106 +0,0 @@
import React, { memo, useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
withSequence,
withDelay,
FadeInDown,
Easing,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
const CBSNexoraLogo = () => {
return (
<View style={styles.logoGrid}>
<View style={styles.logoRow}>
<View style={[styles.logoSquare, { backgroundColor: '#1E40AF' }]} />
<View style={[styles.logoSquare, { backgroundColor: '#3B82F6' }]} />
</View>
<View style={styles.logoRow}>
<View style={[styles.logoSquare, { backgroundColor: '#10B981' }]} />
<View style={[styles.logoSquare, { backgroundColor: '#CBD5E1' }]} />
</View>
</View>
);
};
const LoginHeroComponent = () => {
const theme = useTheme();
return (
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(800).springify()}
style={styles.logoWrapper}
>
<CBSNexoraLogo />
<Text style={[styles.brandName, { color: theme.colors.textPrimary }]}>
CBS<Text style={{ color: theme.colors.primary }}>Nexora</Text>
</Text>
</Animated.View>
<Animated.View entering={FadeInDown.delay(200).duration(800).springify()} style={styles.textContainer}>
<Text style={[styles.welcome, { color: theme.colors.textPrimary }]}>Secure Banking</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Enter your credentials to access your global accounts.
</Text>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 32,
gap: 24,
},
logoWrapper: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
logoGrid: {
gap: 3,
},
logoRow: {
flexDirection: 'row',
gap: 3,
},
logoSquare: {
width: 16,
height: 16,
borderRadius: 3,
},
brandName: {
fontSize: 28,
fontWeight: '800',
letterSpacing: -0.5,
},
textContainer: {
alignItems: 'center',
gap: 6,
paddingHorizontal: 40,
},
welcome: {
fontSize: 24,
fontWeight: '700',
textAlign: 'center',
},
subtitle: {
fontSize: 15,
textAlign: 'center',
fontWeight: '400',
lineHeight: 22,
},
});
export const LoginHero = memo(LoginHeroComponent);
+33 -29
View File
@@ -1,45 +1,49 @@
import React from 'react';
import React, { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../../theme';
export function LoginHero() {
/**
* The brand lock-up at the top of the sign-in screen.
*
* There is no logo file in the repo, so the mark is drawn from the Ionicons
* `shield-checkmark` glyph — a shield with a tick, which is the mark the design
* system specifies.
*
* It sits on the navy wash, so it is drawn in `onBrand` white (7.12:1 at the
* lightest point of the gradient) rather than in `primary`, which would be
* navy on navy.
*
* Deliberately a single quiet row rather than a stacked plate-and-headline.
* The sign-in screen's large type belongs to the customer's own name, and a
* hero that competes with it pushes the MPIN field under the keyboard on the
* 360x640 handsets that are the common case here. It also carries no slogan:
* this screen is a lock, and marketing copy on a lock erodes the trust it
* needs.
*/
const LoginHeroComponent = () => {
const theme = useTheme();
return (
<View style={styles.container}>
<View style={[styles.logoFrame, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="shield-checkmark" size={28} color={theme.colors.primary} />
</View>
<Text style={[styles.heading, { color: theme.colors.textPrimary }]}>BankCBS</Text>
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>Banking made effortless for your everyday money management.</Text>
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
<Text
accessibilityRole="header"
style={[theme.text.h2, { color: theme.colors.onBrandPrimary }]}
>
BankCBS
</Text>
</View>
);
}
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 22,
gap: 12,
gap: 8,
},
logoFrame: {
width: 72,
height: 72,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
heading: {
fontSize: 28,
fontWeight: '800',
textAlign: 'center',
},
description: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
maxWidth: 320,
},
});
});
export const LoginHero = memo(LoginHeroComponent);
+43 -85
View File
@@ -1,98 +1,56 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Svg, { Path } from 'react-native-svg';
import Animated, { FadeIn, FadeOut, Layout } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { haptics } from '../../../services/haptics/haptics';
import React, { useMemo } from 'react';
import { SegmentedControl } from '../../../components';
import { FeatureKey } from '../../../constants/featureKeys';
import { useEnabledItems } from '../../../features';
import type { LoginRole } from '../../../types/app';
interface LoginRoleTabsProps {
activeRole: LoginRole;
onRoleChange: (role: LoginRole) => void;
disabled?: boolean;
}
const ROLES: LoginRole[] = ['User', 'Agent'];
/** Each selectable role, with the feature that controls whether it is offered. */
const ROLE_OPTIONS: Array<{ id: LoginRole; feature: FeatureKey; label: string }> = [
{ id: 'User', feature: FeatureKey.AUTH_LOGIN_USER_ROLE, label: 'Customer' },
{ id: 'Agent', feature: FeatureKey.AUTH_LOGIN_AGENT_ROLE, label: 'Agent' },
];
const ActiveTabBackground = () => (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
layout={Layout.springify().damping(15)}
style={StyleSheet.absoluteFill}
>
<Svg width="100%" height="100%" viewBox="0 0 110 36" preserveAspectRatio="none">
<Path d="M 0,36 L 0,12 Q 0,0 12,0 L 80,0 Q 88,0 92,6 L 110,36 Z" fill="#143e6c" />
</Svg>
</Animated.View>
);
/**
* Chooses which audience is signing in.
*
* This was a pair of hand-drawn SVG tabs with an angled edge, overlapped by a
* negative margin and welded to the form card below by a squared-off corner —
* which meant the card could never change shape, and the tab fill was a
* hardcoded #143e6c that had drifted off the brand navy. It is a segmented
* control now: the same choice, in the shared component, on brand, and
* announced properly as a tab list.
*
* The roles are labelled "Customer" and "Agent" rather than by their internal
* `User` / `Agent` values — "User" is not a word a bank shows a customer.
*/
export const LoginRoleTabs = ({ activeRole, onRoleChange, disabled }: LoginRoleTabsProps) => {
const roles = useEnabledItems(ROLE_OPTIONS);
export const LoginRoleTabs: React.FC<LoginRoleTabsProps> = ({ activeRole, onRoleChange }) => {
const theme = useTheme();
const options = useMemo(
() => roles.map(({ id, label }) => ({ value: id, label })),
[roles],
);
// With only one role available the strip is meaningless, so it is not
// rendered at all — the user never learns another role existed.
if (roles.length < 2) {
return null;
}
return (
<View style={styles.container}>
{ROLES.map((role, index) => {
const isActive = activeRole === role;
return (
<Pressable
key={role}
onPress={() => {
if (!isActive) {
haptics.selection();
onRoleChange(role);
}
}}
style={({ pressed }) => [
styles.tab,
index > 0 && { marginLeft: isActive ? -10 : 0 },
isActive && { zIndex: 10 },
pressed && !isActive && { opacity: 0.6 },
pressed && isActive && { transform: [{ scale: 0.98 }] }
]}
>
{isActive && <ActiveTabBackground />}
<Animated.Text
layout={Layout.springify().damping(15)}
style={[
styles.tabText,
isActive ? styles.activeText : [styles.inactiveText, { color: theme.colors.textSecondary }],
]}
>
{role}
</Animated.Text>
</Pressable>
);
})}
</View>
<SegmentedControl
options={options}
value={activeRole}
onChange={onRoleChange}
disabled={disabled}
accessibilityLabel="Sign in as"
/>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
marginBottom: -1,
zIndex: 10,
marginLeft: 0,
},
tab: {
justifyContent: 'center',
alignItems: 'center',
height: 38,
width: 110,
},
tabText: {
fontSize: 14,
fontWeight: '700',
zIndex: 2,
transform: [{ translateX: -4 }],
},
activeText: {
color: '#FFFFFF',
},
inactiveText: {
fontWeight: '600',
},
});
@@ -0,0 +1,137 @@
import React, { useCallback, useMemo } from 'react';
import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { haptics } from '../../../services/haptics/haptics';
import { appConfig } from '../../../config/appConfig';
import { FeatureKey } from '../../../constants/featureKeys';
import { useEnabledItems } from '../../../features';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
interface PreLoginAction {
id: string;
label: string;
icon: IoniconName;
onPress: () => void;
/** Omitted means always offered. */
feature?: FeatureKey;
}
interface PreLoginActionsProps {
onOpenAccount: () => void;
}
/**
* The row of public actions along the foot of the sign-in screen — the things
* someone can do without being able to get in.
*
* It scrolls horizontally and lays out whatever it is given rather than
* assuming a fixed count, because the actions are feature-gated: with
* registration switched off, "Open an account" is not offered and the row is
* one tile shorter.
*
* Only actions with a real destination are listed. An action that opens nothing
* is worse than an absent one on a bank's sign-in screen, so there is no
* placeholder here — a new tile arrives when the screen behind it does.
*/
export const PreLoginActions = ({ onOpenAccount }: PreLoginActionsProps) => {
const theme = useTheme();
const handleContact = useCallback(() => {
haptics.selection();
// The configured support address, not a hardcoded one, so it follows the
// environment the build points at.
void Linking.openURL(`mailto:${appConfig.supportEmail}`);
}, []);
const actions = useMemo<PreLoginAction[]>(
() => [
{
id: 'open-account',
label: 'Open an account',
icon: 'person-add-outline',
onPress: onOpenAccount,
// Gated by the same feature that registers the Register screen — an
// ungated tile here would navigate to a route that no longer exists.
feature: FeatureKey.AUTH_REGISTER,
},
{
id: 'contact',
label: 'Contact us',
icon: 'mail-outline',
onPress: handleContact,
},
],
[handleContact, onOpenAccount],
);
const visible = useEnabledItems(actions);
if (visible.length === 0) {
return null;
}
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
{visible.map((action) => (
<Pressable
key={action.id}
accessibilityRole="button"
accessibilityLabel={action.label}
onPress={action.onPress}
style={({ pressed }) => [
styles.tile,
{
borderRadius: theme.radius.md,
backgroundColor: pressed
? theme.colors.onBrandSurfaceStrong
: theme.colors.onBrandSurface,
borderColor: theme.colors.onBrandBorder,
},
]}
>
<Ionicons name={action.icon} size={20} color={theme.colors.onBrandPrimary} />
{/* Solid white, not `onBrandSecondary`. The tile's own translucent
fill lightens the wash underneath it, so a translucent label on
top of it lands at 4.37:1 at the top of the gradient — under AA.
Text on an on-brand fill is always the solid tone. */}
<Text
numberOfLines={2}
style={[theme.text.caption, { color: theme.colors.onBrandPrimary }]}
>
{action.label}
</Text>
</Pressable>
))}
{/* Trailing spacer so the last tile can clear the screen edge when the
row is long enough to scroll. */}
<View style={styles.spacer} />
</ScrollView>
);
};
const styles = StyleSheet.create({
row: {
gap: 10,
paddingHorizontal: 16,
},
tile: {
width: 88,
height: 88,
borderWidth: 1,
padding: 12,
// Icon pinned to the top, label to the bottom — the reference layout, and
// it keeps a two-line label from shoving the icon around.
justifyContent: 'space-between',
},
spacer: {
width: 6,
},
});
+379
View File
@@ -0,0 +1,379 @@
import React, { memo, useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type DimensionValue,
type LayoutChangeEvent,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Svg, { Path, Rect } from 'react-native-svg';
import { fontFamily, useTheme } from '../../../theme';
import type { SimSubscription } from '../../../services/device/simCard';
/**
* Lists the lines in this handset and sends the one-time code to whichever the
* customer taps.
*
* Each option is drawn as the SIM itself — the chamfered corner and the contact
* chip — because the question being asked is "which of the two cards in your
* tray is the one your bank has", and the answer lives on a physical object the
* customer can picture. A plain rounded rectangle with a label is a list item;
* this is a picture of the thing.
*
* Cards side by side rather than a column, because the two lines are peers
* being compared, and a stacked list reads top-down as though the first entry
* were the default. The row starts at the left margin, on the same edge as
* the heading and the hero copy above it. Each card is a tap that sends the OTP
* outright:
* registration has no number field for a choice to fill in, so there is no
* confirm step and no primary action to rank the cards against.
*
* A slot whose number the carrier never provisioned cannot be tapped, because
* there is nothing to send a code to. It keeps its card, dimmed and labelled:
* on a dual-SIM handset, silently dropping one line reads as a bug.
*/
type Props = {
sims: SimSubscription[];
/** `subscriptionId` whose code request is in flight, or null when idle. */
busyId: number | null;
onSelect: (sim: SimSubscription) => void;
/** Blocks every card — set while any request is in flight. */
disabled?: boolean;
/**
* Set when this sits directly on the navy brand wash rather than on a light
* surface. Only the heading changes colour: the cards stay light, because the
* number is the one thing on this screen a customer has to read without
* hesitating, and dark-on-light is the most legible way to carry it.
*/
onBrand?: boolean;
};
/** Corner radius of the card silhouette, in points. */
const RADIUS = 12;
/** How much of the top-right corner the chamfer cuts away. */
const CHAMFER = 24;
/**
* The SIM outline, drawn to measured pixel dimensions.
*
* Drawn rather than clipped: React Native will not clip a View to anything but
* a rounded rectangle, and faking the chamfer with a triangle in the background
* colour cannot work here — the card sits on a gradient, so a solid triangle
* would show a seam. As a filled path, everything outside the outline is simply
* transparent and the wash shows through correctly.
*
* It takes real width and height instead of scaling a square viewBox, because
* `preserveAspectRatio="none"` on a non-square card would stretch the corner
* radii and skew the chamfer off 45°.
*/
const SimSilhouette = ({
width,
height,
fill,
stroke,
strokeWidth = 1.5,
}: {
width: number;
height: number;
fill: string;
stroke: string;
strokeWidth?: number;
}) => {
// Inset by half the stroke so the outline sits fully inside the layout box
// rather than being clipped in half along the edges.
const i = strokeWidth / 2;
const w = width - i;
const h = height - i;
// Clamped so the cut can never eat into the rounded corners and invert the
// path. A card this narrow should not happen at 46.5% of any real handset,
// but it can under a 130% system font scale or in split-screen, and a
// self-crossing path renders as a torn shape rather than as nothing.
const chamfer = Math.max(0, Math.min(CHAMFER, w - i - RADIUS * 2, h - i - RADIUS * 2));
const d = [
`M ${i + RADIUS},${i}`,
`L ${w - chamfer},${i}`,
// The cut corner. This one line is what makes the shape a SIM.
`L ${w},${i + chamfer}`,
`L ${w},${h - RADIUS}`,
`Q ${w},${h} ${w - RADIUS},${h}`,
`L ${i + RADIUS},${h}`,
`Q ${i},${h} ${i},${h - RADIUS}`,
`L ${i},${i + RADIUS}`,
`Q ${i},${i} ${i + RADIUS},${i}`,
'Z',
].join(' ');
return (
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
<Path d={d} fill={fill} stroke={stroke} strokeWidth={strokeWidth} strokeLinejoin="round" />
</Svg>
);
};
/**
* The gold contact pad. The chamfer says "SIM" from across the room; the chip is
* what makes it unmistakable up close.
*/
const ContactChip = ({ color, tint }: { color: string; tint: string }) => (
<Svg width={26} height={20} viewBox="0 0 26 20">
<Rect
x={0.7}
y={0.7}
width={24.6}
height={18.6}
rx={3}
fill={tint}
stroke={color}
strokeWidth={1.4}
/>
{/* Two rules across and one down: six pads, which is the pattern on the
cards people actually hold. */}
<Path
d="M0.7,7 H25.3 M0.7,13 H25.3 M13,0.7 V19.3"
fill="none"
stroke={color}
strokeWidth={1}
/>
</Svg>
);
/** What to call a slot: the customer's own label first, then the carrier. */
export const simTitle = (sim: SimSubscription) =>
sim.displayName ?? sim.carrierName ?? `SIM ${sim.slotIndex + 1}`;
const formatNumber = (digits: string) => digits.replace(/(\d{5})(\d{5})/, '$1 $2');
const SimCard = ({
sim,
busy,
blocked,
onPress,
width,
}: {
sim: SimSubscription;
busy: boolean;
blocked: boolean;
onPress: () => void;
width: DimensionValue;
}) => {
const theme = useTheme();
const [box, setBox] = useState({ width: 0, height: 0 });
const [pressed, setPressed] = useState(false);
const number = sim.phoneNumber;
const title = simTitle(sim);
const accent = number ? theme.colors.primary : theme.colors.textMuted;
const handleLayout = (event: LayoutChangeEvent) => {
const { width: w, height: h } = event.nativeEvent.layout;
// Only re-render on a real change; onLayout fires on every parent pass.
setBox((current) =>
Math.abs(current.width - w) < 0.5 && Math.abs(current.height - h) < 0.5
? current
: { width: w, height: h },
);
};
const fill = !number
? theme.colors.surfaceMuted
: pressed
? theme.colors.primarySurface
: theme.colors.surface;
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onLayout={handleLayout}
disabled={blocked}
accessibilityRole="button"
accessibilityState={{ disabled: blocked, busy }}
accessibilityLabel={
number
? `Send a code to ${title}, ${formatNumber(number)}`
: `${title} — your carrier does not share this number`
}
// No borderRadius, no border and no elevation on the view itself: the
// silhouette below carries all three. React Native's shadow and Android's
// elevation both follow the view's rectangular outline, so a lift here
// would draw a square shadow behind a chamfered card.
style={[styles.card, { width }, !number && styles.cardUnusable]}
>
{box.width > 0 ? (
<SimSilhouette
width={box.width}
height={box.height}
fill={fill}
stroke={number ? theme.colors.primary : theme.colors.cardBorder}
/>
) : null}
<View style={styles.content}>
<View style={styles.chipRow}>
<ContactChip
color={accent}
tint={number ? theme.colors.primarySurface : theme.colors.surfaceSunk}
/>
<Text style={[theme.text.eyebrow, styles.slotLabel, { color: accent }]}>
{`SIM ${sim.slotIndex + 1}`}
</Text>
</View>
{/* The number is the card's subject, so it carries the weight. Tabular
figures keep the two cards' digits the same width, so a dual-SIM
pair lines up instead of drifting. */}
<Text
numberOfLines={1}
style={[
styles.number,
{ color: number ? theme.colors.textPrimary : theme.colors.textMuted },
]}
>
{number ? formatNumber(number) : 'Not shared'}
</Text>
<Text
numberOfLines={1}
style={[theme.text.caption, { color: theme.colors.textSecondary }]}
>
{title}
</Text>
{/* No wording here any more. "Get code" restated the heading directly
above it — which already says to tap the SIM — on both cards at
once, and the card itself is the button. The row stays because a tap
fires an OTP request and that needs somewhere visible to report
itself. */}
<View style={styles.action}>
{busy ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<Ionicons
name={number ? 'arrow-forward' : 'close-circle-outline'}
size={18}
color={accent}
/>
)}
</View>
</View>
</Pressable>
);
};
const SimChooserComponent = ({
sims,
busyId,
onSelect,
disabled = false,
onBrand = false,
}: Props) => {
const theme = useTheme();
// A lone card at full width stops reading as a card and starts reading as a
// banner, so a single SIM keeps roughly the footprint it would have had as
// one of a pair, sitting on the left margin rather than stretching.
// Kept under half each so two cards plus the 12pt gap can never overflow the
// row and wrap the second card onto a line of its own.
const cardWidth = sims.length === 1 ? '56%' : '46.5%';
return (
<View style={styles.wrapper}>
<Text
style={[
theme.text.eyebrow,
{ color: onBrand ? theme.colors.onBrandSecondary : theme.colors.textSecondary },
]}
>
{sims.length > 1 ? 'Tap the SIM registered with your bank' : 'Tap your SIM to get a code'}
</Text>
<View style={styles.grid}>
{sims.map((sim) => (
<SimCard
key={sim.subscriptionId}
sim={sim}
busy={sim.subscriptionId === busyId}
blocked={disabled || !sim.phoneNumber}
onPress={() => onSelect(sim)}
width={cardWidth}
/>
))}
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: { gap: 12 },
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
// Left-aligned, not centred: the cards share the left margin with the
// heading directly above them and with the hero copy above that. Centred, a
// lone card floated off that edge and read as an unrelated block dropped
// onto the screen.
justifyContent: 'flex-start',
gap: 12,
},
card: {
// Slightly wider than tall, the way a SIM sits in the tray. Not the true
// 1.4:1 of a nano-SIM: four stacked rows do not fit in the 105pt height
// that would give on a 360pt handset, and an unreadable number is a worse
// outcome than an approximate proportion.
minHeight: 134,
},
cardUnusable: { opacity: 0.7 },
content: {
flex: 1,
// Left-aligned, because the chip sits in a corner on a real SIM and
// centring the rows fights that asymmetry.
alignItems: 'flex-start',
justifyContent: 'center',
gap: 5,
paddingVertical: 13,
paddingHorizontal: 13,
},
chipRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 3,
// The cut corner is at the TOP right, so only this row has to keep clear of
// it. Insetting the whole card would have cost the number 24pt of width it
// needs — at 16pt ExtraBold, "81202 86862" wants nearly all of it.
paddingRight: CHAMFER / 2,
},
slotLabel: {
fontSize: 9,
lineHeight: 12,
letterSpacing: 0.6,
},
number: {
// Mono, like every other figure in the app. At 0.6em per character an
// 11-character number needs ~106pt, and the card's content box is 121pt.
// `fontVariant: ['tabular-nums']` is gone with it: monospace is tabular by
// construction, so the feature flag was asking for something it already had.
fontFamily: fontFamily.monoSemibold,
fontSize: 16,
lineHeight: 22,
letterSpacing: -0.2,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 3,
minHeight: 20,
},
});
export const SimChooser = memo(SimChooserComponent);
+9 -12
View File
@@ -1,13 +1,15 @@
import { useCallback } from 'react';
import { useAppDispatch } from '../../../app/hooks';
import { biometrics } from '../../../services/security/biometrics';
import { authActions } from '../store/authSlice';
export const useBiometricPrompt = () => {
const dispatch = useAppDispatch();
return useCallback(async () => {
/**
* Raises the device's own biometric prompt. Live sign-in does not go through
* this — reading the stored MPIN out of the Keystore raises the platform prompt
* itself, so asking twice would be theatre. This is for the local agent session
* and for confirming an action inside the app.
*/
export const useBiometricPrompt = () =>
useCallback(async () => {
const available = await biometrics.isAvailable();
if (!available) {
@@ -16,10 +18,5 @@ export const useBiometricPrompt = () => {
const result = await biometrics.prompt();
if (result.success) {
dispatch(authActions.setAuthMethod('biometric'));
}
return result.success;
}, [dispatch]);
};
}, []);
@@ -0,0 +1,245 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useTheme, fontFamily } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { biometrics } from '../../../services/security/biometrics';
import { authActions } from '../store/authSlice';
import { selectRegistration } from '../store/authSelectors';
import { completeMpinSetup, rememberRegisteredProfile } from '../store/authThunk';
import { MPIN_LENGTH, describeMpinProblem, isValidMpin } from '../utils/authValidation';
import { CodeInput } from '../components/CodeInput';
import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'CreateMpin'>>;
export default function CreateMpinScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const showApiError = useApiErrorToast();
const registration = useAppSelector(selectRegistration);
const [mpin, setMpin] = useState('');
const [confirmMpin, setConfirmMpin] = useState('');
const [rememberForBiometrics, setRememberForBiometrics] = useState(false);
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
const [submitted, setSubmitted] = useState(false);
const isLoading = registration.status === 'loading';
useEffect(() => {
void biometrics.isAvailable().then(setBiometricsAvailable);
}, []);
// A setup token this screen never received cannot be recovered from here —
// the five-minute window has to be reopened from the start.
useEffect(() => {
if (!registration.setupToken && !isLoading && !submitted) {
dispatch(authActions.resetRegistration());
navigation.navigate('Register');
}
}, [dispatch, isLoading, navigation, registration.setupToken, submitted]);
useEffect(() => {
if (!submitted || registration.status !== 'failed' || !registration.error) {
return;
}
setSubmitted(false);
showApiError(registration.error);
const { code } = registration.error;
if (code === 'OTP_NOT_VERIFIED') {
dispatch(authActions.resetRegistration());
navigation.navigate('Register');
return;
}
if (code === 'MPIN_ALREADY_SET') {
// verify-otp said this profile still owed an MPIN and the server now says
// otherwise, so the profile has to be recorded here — nothing earlier in
// this path stored one, and sign-in would read that as a device that has
// never registered and send them back for another OTP.
void dispatch(
rememberRegisteredProfile({
orgId: registration.orgId,
mobileNumber: registration.mobileNumber,
}),
);
dispatch(authActions.resetRegistration());
navigation.navigate('Login');
return;
}
setMpin('');
setConfirmMpin('');
}, [
dispatch,
navigation,
registration.error,
registration.mobileNumber,
registration.orgId,
registration.status,
showApiError,
submitted,
]);
const mpinProblem = describeMpinProblem(mpin);
const confirmProblem =
confirmMpin.length === MPIN_LENGTH && confirmMpin !== mpin
? 'Both entries must match'
: undefined;
const canSubmit =
isValidMpin(mpin) && confirmMpin === mpin && !isLoading;
const handleSubmit = () => {
if (!canSubmit) {
return;
}
setSubmitted(true);
void dispatch(
completeMpinSetup({
mpin,
rememberForBiometrics: rememberForBiometrics && biometricsAvailable,
}),
);
};
return (
<ScreenShell
title="Create your MPIN"
subtitle="You will use this to sign in from now on"
withTabBar={false}
contentContainerStyle={styles.container}
>
<Card style={styles.formCard}>
<Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>
{`CHOOSE A ${MPIN_LENGTH}-DIGIT MPIN`}
</Text>
<CodeInput
value={mpin}
onChangeText={setMpin}
length={MPIN_LENGTH}
secure
autoFocus
editable={!isLoading}
error={mpinProblem}
/>
<CodeInput
label="Confirm MPIN"
value={confirmMpin}
onChangeText={setConfirmMpin}
length={MPIN_LENGTH}
secure
editable={!isLoading}
error={confirmProblem}
onSubmit={handleSubmit}
/>
</Card>
{biometricsAvailable && (
<Card style={styles.biometricCard}>
<View style={styles.biometricRow}>
<Ionicons name="finger-print-outline" size={22} color={theme.colors.primary} />
<View style={styles.biometricCopy}>
<Text style={[styles.biometricTitle, { color: theme.colors.textPrimary }]}>
Sign in with biometrics
</Text>
{/* Worth being straight about: the API opens a session only
against the MPIN, so enabling this keeps the MPIN on the
device behind the system prompt. */}
<Text style={[styles.biometricNote, { color: theme.colors.textSecondary }]}>
Stores your MPIN in this device&apos;s secure hardware, unlocked by your
fingerprint or face.
</Text>
</View>
<Switch
value={rememberForBiometrics}
onValueChange={setRememberForBiometrics}
disabled={isLoading}
/>
</View>
</Card>
)}
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.warning}14` }]}>
<Ionicons name="alert-circle-outline" size={18} color={theme.colors.warning} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
Three wrong MPINs lock your profile, and only your branch can unlock it. Pick
something you will remember.
</Text>
</View>
<Button
title="Set MPIN and continue"
onPress={handleSubmit}
disabled={!canSubmit}
loading={isLoading}
style={styles.submitButton}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
flexGrow: 1,
},
formCard: {
padding: 16,
gap: 16,
},
sectionLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
marginLeft: 4,
},
biometricCard: {
padding: 16,
},
biometricRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
biometricCopy: {
flex: 1,
gap: 2,
},
biometricTitle: {
fontSize: 15,
fontFamily: fontFamily.bold,
},
biometricNote: {
fontSize: 12,
lineHeight: 17,
},
noteCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
padding: 14,
borderRadius: 12,
},
noteText: {
flex: 1,
fontSize: 13,
lineHeight: 18,
},
submitButton: {
marginTop: 'auto',
height: 54,
},
});
+566 -183
View File
@@ -1,169 +1,566 @@
import React, { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import React, { useEffect, useMemo, useState } from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StatusBar,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import LinearGradient from 'react-native-linear-gradient';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useAppDispatch } from '../../../app/hooks';
import { Button, InfoBanner, Input, Loader } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useTheme } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useToast } from '../../../hooks/useToast';
import { secureStorage } from '../../../services/storage/secureStorage';
import { authActions } from '../store/authSlice';
import { useLoginMutation } from '../services/authApi';
import { isValidPhone } from '../utils/authValidation';
import { LoginHero } from '../components/LoginHero';
import { NeuralGrid } from '../components/NeuralGrid';
import { BiometricNode } from '../components/BiometricNode';
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
import { haptics } from '../../../services/haptics/haptics';
import { biometrics } from '../../../services/security/biometrics';
import { authActions } from '../store/authSlice';
import {
selectBiometricReady,
selectLastProfile,
selectMpinLocked,
selectSignInContextLoaded,
selectSignInError,
selectSignInStatus,
} from '../store/authSelectors';
import {
loadSignInContext,
signIn,
signInAsAgent,
signInWithBiometrics,
} from '../store/authThunk';
import { MPIN_LENGTH, isValidMpin, isValidPhone } from '../utils/authValidation';
import { LoginHero } from '../components/LoginHero';
import { BiometricNode } from '../components/BiometricNode';
import { CodeInput } from '../components/CodeInput';
import { PreLoginActions } from '../components/PreLoginActions';
import { LoginFooter } from '../components/LoginFooter';
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
import type { AuthStackParamList } from '../../../navigation/types';
import type { LoginRole } from '../../../types/app';
import { LoginRoleTabs } from '../components/LoginRoleTabs';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate, useFeature } from '../../../features';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Login'>>;
export default function LoginScreen(props: Readonly<Props>) {
export default function LoginScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const showApiError = useApiErrorToast();
const promptBiometric = useBiometricPrompt();
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const lastProfile = useAppSelector(selectLastProfile);
const contextLoaded = useAppSelector(selectSignInContextLoaded);
const biometricReady = useAppSelector(selectBiometricReady);
const signInStatus = useAppSelector(selectSignInStatus);
const signInError = useAppSelector(selectSignInError);
const mpinLocked = useAppSelector(selectMpinLocked);
const [mobile, setMobile] = useState('');
const [mpin, setMpin] = useState('');
const [activeRole, setActiveRole] = useState<LoginRole>('User');
const [showPassword, setShowPassword] = useState(false);
const [login, loginState] = useLoginMutation();
const [rememberForBiometrics, setRememberForBiometrics] = useState(false);
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
/** Set once the customer chooses the MPIN path over the biometric one. */
const [mpinRequested, setMpinRequested] = useState(false);
const [keyboardUp, setKeyboardUp] = useState(false);
/**
* The last failure, held for display on the form itself. The toast that
* `showApiError` raises is gone in a few seconds, and the effect below clears
* the MPIN boxes at the same time — so without this, a wrong MPIN looks like
* the app simply did nothing.
*/
const [formError, setFormError] = useState<string | undefined>();
const handleForgotPassword = () => {
haptics.selection();
toast('Resetting your password', 'info', 'We are taking you to reset your password.');
};
const handleSignUp = () => {
haptics.selection();
toast('Creating your account', 'info', 'We are taking you to open a new account.');
};
const userRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_USER_ROLE);
const agentRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_AGENT_ROLE);
const handleContinue = async () => {
try {
const tokens = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
role: activeRole,
};
await secureStorage.saveSession(tokens);
dispatch(authActions.authenticate(tokens));
haptics.success();
toast('Signed in', 'success', 'Welcome back to your account.');
return;
} catch {
haptics.error();
toast('Sign-in failed', 'error', 'We could not verify your details. Please check and try again.');
const isLoading = signInStatus === 'loading';
/**
* A profile stored on this device means registration already completed here,
* so the customer only owes us an MPIN — the number is not theirs to retype.
* The agent app has no stored profile, so it keeps the full form.
*/
const isReturningCustomer = activeRole === 'User' && Boolean(lastProfile);
useEffect(() => {
void biometrics.isAvailable().then(setBiometricsAvailable);
}, []);
// The public action row is pinned to the foot of the screen, which is exactly
// where the keyboard lands. Hiding it while the keyboard is up keeps it from
// being squeezed into a sliver above the keys.
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const shown = Keyboard.addListener(showEvent, () => setKeyboardUp(true));
const hidden = Keyboard.addListener(hideEvent, () => setKeyboardUp(false));
return () => {
shown.remove();
hidden.remove();
};
}, []);
// Several routes reach this screen after the store was wiped — an ordinary
// logout, or a stored session found dead at launch. Re-reading the remembered
// profile here covers all of them rather than one at a time.
useEffect(() => {
if (!contextLoaded) {
void dispatch(loadSignInContext());
}
// Mount-only: once loaded, re-running would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch]);
// First launch is routed straight to registration by AuthNavigator, so this
// only catches the late case: a logout wiped the store and the re-read found
// no profile (app data cleared, or the device was unbound). By then the
// navigator is long since ready, so the action is safe to dispatch here.
useEffect(() => {
if (contextLoaded && !lastProfile && activeRole === 'User') {
navigation.replace('Register');
}
}, [activeRole, contextLoaded, lastProfile, navigation]);
useEffect(() => {
if (lastProfile) {
setMobile((current) => current || lastProfile.mobileNumber);
}
}, [lastProfile]);
// Keep the selected role on something that is actually offered — otherwise a
// disabled role could still be submitted via the default state.
useEffect(() => {
if (activeRole === 'User' && !userRoleEnabled && agentRoleEnabled) {
setActiveRole('Agent');
} else if (activeRole === 'Agent' && !agentRoleEnabled && userRoleEnabled) {
setActiveRole('User');
}
}, [activeRole, agentRoleEnabled, userRoleEnabled]);
// A locked profile has no in-app path at all, so it gets its own screen
// rather than a toast the user can dismiss and keep hammering the form.
useEffect(() => {
if (mpinLocked) {
navigation.navigate('MpinLocked');
}
}, [mpinLocked, navigation]);
useEffect(() => {
if (signInStatus !== 'failed' || !signInError) {
return;
}
// MPIN_LOCKED gets the dedicated screen above; a cancelled biometric
// prompt is a user decision, not a failure worth shouting about.
if (signInError.code === 'MPIN_LOCKED') {
return;
}
if (signInError.code === 'BIOMETRIC_CANCELLED') {
toast('Biometric sign-in cancelled', 'info', 'You can sign in with your MPIN instead.');
dispatch(authActions.clearSignInError());
return;
}
showApiError(signInError);
// A device that was never bound, or a profile with no MPIN yet, cannot sign
// in whatever the MPIN — registration is the only way forward.
if (signInError.code === 'DEVICE_NOT_BOUND' || signInError.code === 'MPIN_NOT_SET') {
navigation.navigate('Register');
} else {
// Every other failure leaves the customer on this screen, so the reason
// has to stay on this screen with them — and the MPIN field comes out,
// since a biometric failure needs a way through that is not biometrics.
setFormError(signInError.message);
setMpinRequested(true);
}
setMpin('');
dispatch(authActions.clearSignInError());
}, [dispatch, navigation, showApiError, signInError, signInStatus, toast]);
const mobileError = useMemo(() => {
if (mobile.length === 0 || isValidPhone(mobile)) {
return undefined;
}
return 'Enter the 10-digit number registered with your bank';
}, [mobile]);
const canSubmit = isValidPhone(mobile) && isValidMpin(mpin) && !isLoading;
// Only worth offering once a profile on this device has actually stored an
// MPIN behind the device prompt — otherwise the button can only ever fail.
const canUseBiometrics = activeRole === 'Agent' || biometricReady;
const biometricsGranted = useFeature(FeatureKey.AUTH_LOGIN_BIOMETRIC);
/**
* Biometrics lead only for a returning customer who has them ready: the
* device knows who they are, so one tap is the whole sign-in. Everyone else —
* an agent, or a customer with nothing enrolled — needs the form anyway, and
* hiding it behind a button would just add a tap to the only way in.
*/
const biometricsLead = canUseBiometrics && biometricsGranted && isReturningCustomer;
const formVisible = !biometricsLead || mpinRequested;
// Switching role changes which of the two paths applies, so the disclosure
// resets with it rather than leaving the form open from the previous role.
useEffect(() => {
setMpinRequested(false);
setFormError(undefined);
setMpin('');
}, [activeRole]);
const handleMobileChange = (value: string) => {
setFormError(undefined);
setMobile(value.replace(/\D/g, ''));
};
const handleMpinChange = (value: string) => {
// Retyping is the customer acting on the error, so the notice goes away as
// soon as they start — leaving it up would read as a second failure.
setFormError(undefined);
setMpin(value);
};
const handleSignIn = () => {
if (!canSubmit) {
return;
}
if (activeRole === 'Agent') {
// The field-agent app has no CBS backend behind it yet.
void dispatch(signInAsAgent({ mobileNumber: mobile }));
return;
}
void dispatch(
signIn({
mobileNumber: mobile,
mpin,
orgId: lastProfile?.orgId,
// This sign-in is the only place the MPIN is verified, so it is the
// only safe moment to store it for biometric use.
rememberForBiometrics: rememberForBiometrics && biometricsAvailable,
}),
);
};
const handleBiometricLogin = async () => {
haptics.selection();
const success = await promptBiometric();
if (!success) {
toast('Biometric sign-in cancelled', 'info', 'You can try again or sign in with your password.');
if (activeRole === 'Agent') {
const confirmed = await promptBiometric();
if (!confirmed) {
toast('Biometric sign-in cancelled', 'info', 'You can sign in with your MPIN instead.');
return;
}
void dispatch(signInAsAgent({ mobileNumber: mobile || '0000000000' }));
return;
}
const tokens = {
accessToken: 'biometric-access-token',
refreshToken: 'biometric-refresh-token',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
role: activeRole,
};
await secureStorage.saveSession(tokens);
dispatch(authActions.authenticate(tokens));
haptics.success();
toast('Signed in', 'success', 'Your identity was confirmed. Welcome back.');
// Reading the stored MPIN raises the platform's own prompt, so there is no
// separate biometric check to run first.
void dispatch(signInWithBiometrics());
};
const handleRegisterAgain = () => {
haptics.selection();
dispatch(authActions.resetRegistration());
navigation.navigate('Register');
};
const handleUseMpin = () => {
haptics.selection();
setMpinRequested(true);
};
/**
* Indian mobile grouping is 5 + 5. Masking the first six digits and grouping
* what is left the same way keeps the number recognisable in the shape the
* customer knows it in, without printing it in full on a locked screen.
*/
const maskedMobile = lastProfile?.mobileNumber
? `+91 ••••• •${lastProfile.mobileNumber.slice(-4)}`
: undefined;
const gradient = (
<>
<LinearGradient
colors={theme.colors.brandGradient}
style={StyleSheet.absoluteFill}
// A vertical wash. Two of the three stops carry the brand navy itself,
// so the screen still reads as one colour rather than as a fade.
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* The wash is dark, so the status bar icons have to be light — the
opposite of every other screen in the app. */}
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
</>
);
// Deciding between the MPIN screen and registration needs the stored profile.
// Rendering either one first would flash the wrong screen.
if (!contextLoaded) {
return (
<View style={styles.flex}>
{gradient}
<SafeAreaView edges={['top']} style={[styles.flex, styles.centre]}>
<Loader />
</SafeAreaView>
</View>
);
}
return (
<View style={styles.flex}>
<ScreenShell scrollable={true} contentContainerStyle={styles.shellContainer}>
<View style={styles.content}>
<View style={styles.heroWrapper}>
<LoginHero />
</View>
{gradient}
<Animated.View entering={FadeInUp.delay(400).duration(800).springify()}>
<LoginRoleTabs activeRole={activeRole} onRoleChange={setActiveRole} />
<Card
style={[
styles.formCard,
activeRole === 'User' ? { borderTopLeftRadius: 0 } : {}
]}
>
<View style={styles.inputGap}>
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>EMAIL OR PHONE</Text>
<Input
keyboardType="default"
value={phone}
onChangeText={setPhone}
placeholder="name@example.com"
/>
</View>
<View style={styles.inputGap}>
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>PASSWORD</Text>
<Input
secureTextEntry={!showPassword}
value={password}
onChangeText={setPassword}
placeholder="••••••••"
rightAccessory={
<Pressable onPress={() => setShowPassword(!showPassword)} hitSlop={12}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={theme.colors.textMuted}
/>
</Pressable>
}
/>
</View>
<View style={styles.forgotPasswordRow}>
<Pressable onPress={handleForgotPassword} hitSlop={12}>
<Text style={[styles.linkText, { color: theme.colors.primary }]}>Forgot password?</Text>
</Pressable>
</View>
<Button
title="Sign In"
loading={loginState.isLoading}
onPress={handleContinue}
style={styles.mainButton}
/>
<View style={styles.dividerRow}>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<Text style={[styles.dividerText, { color: theme.colors.textMuted }]}>OR SIGN IN WITH</Text>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
</View>
<BiometricNode onPress={handleBiometricLogin} />
</Card>
</Animated.View>
<Animated.View
entering={FadeInDown.delay(600).duration(800)}
style={styles.bottomLinks}
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
<Text style={[styles.signUpText, { color: theme.colors.textSecondary }]}>
New to BankCBS?{' '}
<Text style={[styles.signUpLink, { color: theme.colors.primary }]} onPress={handleSignUp}>Create account</Text>
</Text>
</Animated.View>
</View>
</ScreenShell>
{/* ── brand lock-up ─────────────────────────────────────────── */}
<LoginHero />
{/* ── who is signing in ─────────────────────────────────────── */}
<LoginRoleTabs
activeRole={activeRole}
onRoleChange={setActiveRole}
disabled={isLoading}
/>
{/* ── greeting ──────────────────────────────────────────────── */}
<View style={styles.greeting}>
{isReturningCustomer && lastProfile?.fullName ? (
<>
<Text style={[theme.text.bodySmall, { color: theme.colors.onBrandSecondary }]}>
Welcome back
</Text>
<Text
numberOfLines={2}
style={[theme.text.h1, styles.centreText, { color: theme.colors.onBrandPrimary }]}
>
{lastProfile.fullName}
</Text>
{maskedMobile ? (
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
{maskedMobile}
</Text>
) : null}
</>
) : (
<>
<Text
style={[
theme.text.h1,
styles.centreText,
{ color: theme.colors.onBrandPrimary },
]}
>
{activeRole === 'Agent' ? 'Agent sign-in' : 'Welcome'}
</Text>
{/*
A returning customer whose name we do not hold yet still
gets told which profile they are signing into. That is the
device-rebind path: verify-otp stores the mobile number but
no name, because it does not return one — so without this
the screen asks for an MPIN and names nothing at all.
*/}
{maskedMobile ? (
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
{maskedMobile}
</Text>
) : null}
</>
)}
</View>
{/* ── the way in ────────────────────────────────────────────── */}
{biometricsLead ? (
<Animated.View entering={FadeIn.duration(320)} style={styles.heroBlock}>
<BiometricNode
variant="hero"
onPress={handleBiometricLogin}
disabled={isLoading}
caption="Tap to sign in"
/>
</Animated.View>
) : null}
{formVisible ? (
<Animated.View entering={FadeInUp.duration(280)} style={styles.form}>
{!isReturningCustomer ? (
<Input
label="Registered mobile number"
prefix="+91"
keyboardType="number-pad"
maxLength={10}
value={mobile}
onChangeText={handleMobileChange}
placeholder="98765 43210"
error={mobileError}
editable={!isLoading}
/>
) : null}
<CodeInput
label="Enter MPIN"
value={mpin}
onChangeText={handleMpinChange}
length={MPIN_LENGTH}
secure
autoFocus={mpinRequested || isReturningCustomer}
editable={!isLoading}
onSubmit={handleSignIn}
/>
{formError ? <InfoBanner tone="danger" message={formError} /> : null}
{/* Offered only where it can actually be turned on: biometrics
enrolled, a customer sign-in, and nothing stored yet. */}
{activeRole === 'User' && biometricsAvailable && !biometricReady ? (
<View style={styles.rememberRow}>
<Text
style={[
theme.text.bodySmall,
styles.rememberLabel,
{ color: theme.colors.onBrandSecondary },
]}
>
Enable biometric sign-in next time
</Text>
<Switch
value={rememberForBiometrics}
onValueChange={setRememberForBiometrics}
disabled={isLoading}
accessibilityLabel="Enable biometric sign-in next time"
// The thumb stays white in both states. A navy thumb on
// the translucent-white track composites to roughly
// 1.7:1 against it and effectively disappears on the
// navy wash.
trackColor={{
false: theme.colors.onBrandSurfaceStrong,
true: theme.colors.success,
}}
thumbColor={theme.colors.onBrandPrimary}
/>
</View>
) : null}
{/* White fill, navy label — `primary` here would be navy on
navy and disappear into the wash. */}
<Button
title="Sign in"
variant="inverse"
loading={isLoading}
disabled={!canSubmit}
onPress={handleSignIn}
/>
{/* An agent, and a customer whose biometrics are enrolled but
not yet bound to a stored MPIN, never see the hero target
above — so their biometric path lives here instead, or they
would have none at all. */}
{canUseBiometrics && biometricsGranted && !biometricsLead ? (
<>
<View style={styles.dividerRow}>
<View
style={[styles.divider, { backgroundColor: theme.colors.onBrandBorder }]}
/>
<Text style={[theme.text.eyebrow, { color: theme.colors.onBrandMuted }]}>
or sign in with
</Text>
<View
style={[styles.divider, { backgroundColor: theme.colors.onBrandBorder }]}
/>
</View>
<BiometricNode onPress={handleBiometricLogin} disabled={isLoading} />
</>
) : null}
</Animated.View>
) : (
<Button
title="Sign in with MPIN"
variant="onBrand"
onPress={handleUseMpin}
disabled={isLoading}
rightAccessory={
<Ionicons
name="chevron-forward"
size={16}
color={theme.colors.onBrandPrimary}
/>
}
style={styles.mpinSwitch}
/>
)}
{/* ── footnotes ─────────────────────────────────────────────── */}
<View style={styles.footnotes}>
<FeatureGate feature={FeatureKey.AUTH_REGISTER}>
<Text style={[theme.text.bodySmall, { color: theme.colors.onBrandSecondary }]}>
{isReturningCustomer ? 'Not you? ' : 'New to BankCBS? '}
<Text
accessibilityRole="link"
style={{ color: theme.colors.onBrandPrimary }}
onPress={handleRegisterAgain}
>
Register
</Text>
</Text>
</FeatureGate>
{/* There is no forgot-MPIN or reset flow in the API — by policy,
not by omission — so the app says so instead of offering a
dead link. */}
<Text
style={[theme.text.caption, styles.centreText, { color: theme.colors.onBrandMuted }]}
>
Forgot your MPIN? Visit your branch to have it reset.
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/*
Both of these are hidden while the keyboard is up. They are the
least urgent things on the screen, and the MPIN field needs the
height more than a legal line does.
*/}
{!keyboardUp ? (
<View style={styles.actions}>
<PreLoginActions onOpenAccount={handleRegisterAgain} />
<LoginFooter />
</View>
) : null}
</SafeAreaView>
</View>
);
}
@@ -172,77 +569,63 @@ const styles = StyleSheet.create({
flex: {
flex: 1,
},
shellContainer: {
flexGrow: 1,
paddingHorizontal: 20,
},
content: {
flex: 1,
centre: {
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 40,
},
heroWrapper: {
marginBottom: 8,
centreText: {
textAlign: 'center',
},
formCard: {
gap: 20,
padding: 24,
borderRadius: 24,
backgroundColor: '#FFFFFF',
// We'll rely on the Card component's internal variant/shadow or override
scroll: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 26,
paddingHorizontal: 24,
paddingVertical: 28,
},
inputGap: {
gap: 8,
greeting: {
alignItems: 'center',
gap: 4,
},
inputLabel: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.5,
heroBlock: {
alignItems: 'center',
paddingVertical: 4,
},
forgotPasswordRow: {
form: {
width: '100%',
gap: 16,
},
mpinSwitch: {
// Narrower than the full gutter width, so the pill reads as one option
// among several rather than as the screen's committed action.
width: '88%',
},
rememberRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: -8,
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
linkText: {
fontSize: 14,
fontWeight: '600',
},
mainButton: {
height: 54,
borderRadius: 12,
rememberLabel: {
flex: 1,
},
dividerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
marginVertical: 4,
},
divider: {
flex: 1,
height: 1,
},
dividerText: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.5,
},
bottomLinks: {
gap: 16,
footnotes: {
alignItems: 'center',
marginTop: 32,
gap: 8,
},
signUpText: {
fontSize: 15,
fontWeight: '500',
},
signUpLink: {
fontWeight: '700',
actions: {
gap: 14,
paddingBottom: 8,
},
});
@@ -0,0 +1,97 @@
import React from 'react';
import { Linking, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch } from '../../../app/hooks';
import { useTheme, fontFamily } from '../../../theme';
import { appConfig } from '../../../config/appConfig';
import { authActions } from '../store/authSlice';
import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'MpinLocked'>>;
/**
* A locked profile is a dead end by policy: there is no forgot-MPIN flow and no
* in-app reset anywhere in the API. Saying so plainly beats a retry button that
* can only ever fail.
*/
export default function MpinLockedScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const handleBackToSignIn = () => {
dispatch(authActions.dismissMpinLock());
navigation.navigate('Login');
};
const handleContactSupport = () => {
void Linking.openURL(`mailto:${appConfig.supportEmail}?subject=MPIN%20locked`);
};
return (
<ScreenShell
title="MPIN locked"
subtitle="Too many incorrect attempts"
withTabBar={false}
contentContainerStyle={styles.container}
>
<View style={[styles.iconWrap, { backgroundColor: `${theme.colors.error}14` }]}>
<Ionicons name="lock-closed-outline" size={40} color={theme.colors.error} />
</View>
<Card style={styles.card}>
<Text style={[styles.heading, { color: theme.colors.textPrimary }]}>
Visit your branch to unlock
</Text>
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
For your security, your profile locks after three incorrect MPIN entries. There is no
way to unlock it from the app — a branch officer has to reset it in person.
</Text>
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
Take a valid photo ID with you. Once your MPIN is reset you can sign in here as usual.
</Text>
</Card>
<View style={styles.actions}>
<Button title="Contact support" variant="secondary" onPress={handleContactSupport} />
<Button title="Back to sign in" variant="ghost" onPress={handleBackToSignIn} />
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 20,
flexGrow: 1,
alignItems: 'stretch',
},
iconWrap: {
alignSelf: 'center',
width: 88,
height: 88,
borderRadius: 44,
alignItems: 'center',
justifyContent: 'center',
marginTop: 12,
},
card: {
padding: 20,
gap: 12,
},
heading: {
fontSize: 18,
fontFamily: fontFamily.bold,
},
body: {
fontSize: 14,
lineHeight: 20,
},
actions: {
marginTop: 'auto',
gap: 12,
},
});
+733
View File
@@ -0,0 +1,733 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
AppState,
KeyboardAvoidingView,
Linking,
Platform,
Pressable,
ScrollView,
StatusBar,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import LinearGradient from 'react-native-linear-gradient';
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
import { Button, InfoBanner, Input } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useTheme } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { isValidPhone } from '../../../utils/validation';
import { simCard, type SimInfo, type SimSubscription } from '../../../services/device/simCard';
import { SimChooser } from '../components/SimChooser';
import { authActions } from '../store/authSlice';
import { selectLastProfile, selectRegistration } from '../store/authSelectors';
import { rememberRegisteredProfile, startRegistration } from '../store/authThunk';
import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Register'>>;
/**
* Three short marks of what this flow is, in the shape a bank's onboarding
* screen usually carries them.
*
* Each one is literally true of this app, which is the only reason it is
* allowed on the screen: the MPIN really is RSA-OAEP encrypted before it leaves
* the handset, the number really is read off the SIM when the phone will share
* it, and the profile really is bound to this device. Nothing here claims a
* certification, an audit, or a regulator — those would be invented, and an
* invented security claim on a bank's sign-up screen is a straightforward lie
* to a customer.
*/
const TRUST_MARKS = [
{ icon: 'shield-checkmark' as const, label: 'Secure' },
{ icon: 'flash' as const, label: 'Seamless' },
{ icon: 'phone-portrait' as const, label: 'Trusted device' },
];
export default function RegisterScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const showApiError = useApiErrorToast();
const registration = useAppSelector(selectRegistration);
const lastProfile = useAppSelector(selectLastProfile);
const [sim, setSim] = useState<SimInfo | null>(null);
/** Which SIM's code request is in flight, so only that row spins. */
const [sendingId, setSendingId] = useState<number | null>(null);
const [detecting, setDetecting] = useState(true);
const [submitted, setSubmitted] = useState(false);
/** Bumped to re-read the SIMs. Never prompts — see the mount effect. */
const [attempt, setAttempt] = useState(0);
/**
* Android has stopped showing the permission dialog for this app. Asking
* again would return instantly and change nothing, so the only honest next
* step is app settings.
*/
const [permissionBlocked, setPermissionBlocked] = useState(false);
/**
* The typed-number fallback.
*
* Reading the SIM covers the common case, but a carrier that never wrote the
* MSISDN onto the card, a denied permission and a phone with no SIM in it
* each ended the flow outright — the screen said to contact the bank,
* because there was nowhere to put a number. It can be typed now, so none of
* those three is a dead end.
*/
const [manualOpen, setManualOpen] = useState(false);
const [manualNumber, setManualNumber] = useState('');
const isLoading = registration.status === 'loading';
const sims = sim?.sims ?? [];
// A line whose number the carrier never provisioned cannot be tapped — there
// is nothing to send from it. Typing the number is the way past that.
const tappable = sims.filter((entry) => entry.phoneNumber);
/**
* Reads on mount, and never prompts here.
*
* This used to call a `detect()` that raised the system permission dialog the
* instant the screen appeared — before the customer had read a word of what
* the screen was for, which is precisely how an app trains people to refuse.
* The prompt now belongs to a button the customer presses knowing what it
* will do.
*/
useEffect(() => {
let cancelled = false;
setDetecting(true);
void simCard.read().then((info) => {
if (cancelled) {
return;
}
setSim(info);
setDetecting(false);
});
return () => {
cancelled = true;
};
}, [attempt]);
// Coming back from app settings is the one case where the answer changes
// while this screen is mounted but nothing here was touched. Re-reading on
// foreground is what makes the settings round-trip actually finish.
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
setAttempt((value) => value + 1);
}
});
return () => subscription.remove();
}, []);
useEffect(() => {
if (!submitted) {
return;
}
if (registration.status === 'succeeded' && registration.otpSent) {
setSubmitted(false);
setSendingId(null);
navigation.navigate('VerifyOtp');
return;
}
if (registration.status === 'failed' && registration.error) {
setSubmitted(false);
setSendingId(null);
showApiError(registration.error);
// Registration is already complete for this number — what the customer
// wants is sign-in, not another OTP.
if (
registration.error.code === 'ALREADY_REGISTERED' ||
registration.error.code === 'MPIN_ALREADY_SET'
) {
// Recorded before the reset clears the number, and before the navigate
// below: sign-in reads the stored profile to tell a device that has
// completed registration from one that never has, and without it this
// number would be sent straight back here for another OTP.
void dispatch(
rememberRegisteredProfile({
orgId: registration.orgId,
mobileNumber: registration.mobileNumber,
}),
);
dispatch(authActions.resetRegistration());
navigation.navigate('Login');
}
}
}, [dispatch, navigation, registration, showApiError, submitted]);
/** One tap sends the code — there is no separate submit step any more. */
const handleSelectSim = (chosen: SimSubscription) => {
const number = chosen.phoneNumber;
if (!number || isLoading) {
return;
}
setSendingId(chosen.subscriptionId);
setSubmitted(true);
// orgId is fixed for this build and comes from config, so there is no field
// for it. The chosen SIM is not sent either: the API takes a number alone.
void dispatch(startRegistration({ mobileNumber: number }));
};
// Ten digits or nothing — the same contract the SIM read normalises to, so a
// typed number and a read one reach the API in one shape.
const manualDigits = manualNumber.replace(/D/g, '');
/** Silent until there is something to be wrong about, as sign-in is. */
const manualError = (() => {
if (manualDigits.length === 0 || isValidPhone(manualDigits)) {
return undefined;
}
return 'Enter the 10-digit number your bank has on file.';
})();
const handleManualChange = (value: string) => {
setManualNumber(value.replace(/D/g, ''));
};
const handleManualSubmit = () => {
if (!isValidPhone(manualDigits) || isLoading) {
return;
}
// Left null on purpose: no SIM row is sending, so none of them should spin.
setSendingId(null);
setSubmitted(true);
void dispatch(startRegistration({ mobileNumber: manualDigits }));
};
/**
* Why there is nothing to tap, and what the customer can do about it. Null
* whenever at least one line carries a readable number.
*/
const blocker = (() => {
if (detecting || !sim || tappable.length > 0) {
return null;
}
if (!sim.hasPermission) {
return {
icon: 'lock-closed-outline' as const,
title: 'Allow phone access',
text: 'Your number is read straight from the SIM, so you never have to type it. Nothing else on this phone is read.',
action: 'Allow phone access',
kind: 'permission' as const,
};
}
/*
* The slots listed but no number came back, and the reason is a permission
* this app has not been given — not the carrier.
*
* This case had no branch at all before, so it fell through to the carrier
* message below and told the customer to visit a branch. On Android 13 and
* up it is the likeliest failure of the three, because READ_PHONE_NUMBERS
* is a separate grant from READ_PHONE_STATE.
*/
if (!sim.canReadNumbers) {
return {
icon: 'key-outline' as const,
title: 'One more permission needed',
text: 'This phone keeps the SIM number behind its own permission. Allow it and your number is filled in for you.',
action: 'Allow and continue',
kind: 'permission' as const,
};
}
if (!sim.hasSim) {
return {
icon: 'phone-portrait-outline' as const,
title: 'No SIM found',
text: 'Put the SIM whose number your bank has into this phone, then check again.',
action: 'Check again',
kind: 'retry' as const,
};
}
// The ordinary case: a SIM is present, the app may read numbers, and the
// carrier still never wrote one onto the SIM — so there is nothing to read
// and nowhere to send a code.
return {
icon: 'information-circle-outline' as const,
title: 'Your carrier does not share this number',
text:
sims.length > 1
? 'Neither line lets apps read its number, so there is nothing to tap. Type the number in and the code still comes through.'
: 'This SIM does not let apps read its number, so there is nothing to tap. Type the number in and the code still comes through.',
action: 'Check again',
kind: 'retry' as const,
};
})();
/** True once Android has stopped offering the dialog for a permission ask. */
const needsSettings = blocker?.kind === 'permission' && permissionBlocked;
const handleBlockerAction = useCallback(async () => {
if (blocker?.kind !== 'permission') {
setAttempt((value) => value + 1);
return;
}
if (permissionBlocked) {
// The AppState listener above re-reads when the customer comes back.
void Linking.openSettings();
return;
}
const { outcome, info } = await simCard.requestAndRead();
setSim(info);
if (outcome === 'blocked') {
setPermissionBlocked(true);
}
}, [blocker?.kind, permissionBlocked]);
const numberError = registration.error?.fieldErrors?.mobileNumber;
const manualLink = (
<Pressable onPress={() => setManualOpen(true)} hitSlop={6}>
<Text
style={[theme.text.bodySmall, styles.manualLink, { color: theme.colors.onBrandSecondary }]}
>
Number not showing?{' '}
<Text style={{ color: theme.colors.onBrandPrimary }}>Type it instead</Text>
</Text>
</Pressable>
);
/*
A solid surface, not the translucent `onBrandSurface` the blocker sits on.
The field inside carries the design system's light-context colours — a
dark-grey label over half-transparent white over navy is not readable — so
the card brings its own light surface, exactly as the InfoBanner does.
*/
const manualCard = (
<View
style={[
styles.manual,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.lg,
},
]}
>
<Text style={[theme.text.h3, { color: theme.colors.textPrimary }]}>
Enter your mobile number
</Text>
<Text style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}>
The number your bank holds for you. We&apos;ll text a one-time code to it.
</Text>
<Input
label="Mobile number"
prefix="+91"
keyboardType="number-pad"
maxLength={10}
value={manualNumber}
onChangeText={handleManualChange}
onSubmitEditing={handleManualSubmit}
returnKeyType="done"
placeholder="98765 43210"
autoFocus
editable={!isLoading}
// The server's own complaint about the number belongs on the field that
// holds it, rather than in a banner elsewhere on the screen.
error={manualError ?? numberError}
/>
<Button
title="Send code"
onPress={handleManualSubmit}
disabled={!isValidPhone(manualDigits)}
// Only a manual send leaves `sendingId` null, so a SIM row's request
// cannot put this button into a spin it does not own.
loading={isLoading && sendingId === null}
/>
<Pressable onPress={() => setManualOpen(false)} hitSlop={6}>
<Text
style={[
theme.text.bodySmall,
styles.centreText,
styles.manualDismiss,
{ color: theme.colors.textSecondary },
]}
>
{tappable.length > 0 ? 'Use a SIM instead' : 'Back'}
</Text>
</Pressable>
</View>
);
return (
<View style={styles.flex}>
{/*
The brand wash now fills the screen rather than sitting in a rounded
banner at the top. It is the same three stops the sign-in screen uses,
run vertically, so the two auth screens are one surface rather than two
different navies.
*/}
<LinearGradient
colors={theme.colors.brandGradient}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Dark full-bleed surface, so the status-bar icons have to be light. */}
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
{/* The number can be typed on this screen now, so it needs the same
keyboard handling sign-in has — without it the field and the send
button sit under the keyboard on a short handset. */}
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
{navigation.canGoBack() ? (
<Pressable
onPress={() => navigation.goBack()}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel="Go back"
style={[styles.back, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
>
<Ionicons name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
</Pressable>
) : null}
{/* ── Brand and purpose ──────────────────────────────────────────── */}
<Animated.View entering={FadeInDown.duration(420)} style={styles.heroBody}>
<View style={styles.brandRow}>
<View
style={[
styles.mark,
{
backgroundColor: theme.colors.onBrandSurfaceStrong,
borderRadius: theme.radius.sm,
},
]}
>
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
</View>
<Text style={[theme.text.h1, { color: theme.colors.onBrandPrimary }]}>BankCBS</Text>
</View>
<Text
accessibilityRole="header"
style={[theme.text.display, styles.heroTitle, { color: theme.colors.onBrandPrimary }]}
>
Let&apos;s set up{'\n'}your banking
</Text>
<Text
style={[theme.text.body, styles.heroSub, { color: theme.colors.onBrandSecondary }]}
>
Tap the SIM registered with your bank. We&apos;ll read the number straight from it
and text you a one-time code.
</Text>
<View style={styles.trustRow}>
{TRUST_MARKS.map((item) => (
<View
key={item.label}
style={[
styles.trustChip,
{
backgroundColor: theme.colors.onBrandSurface,
borderColor: theme.colors.onBrandBorder,
borderRadius: theme.radius.pill,
},
]}
>
<Ionicons name={item.icon} size={13} color={theme.colors.onBrandPrimary} />
{/* Solid white: the chip's translucent fill lightens the wash,
so a translucent label on it drops to 4.37:1 up here where
the gradient is lightest. */}
<Text style={[theme.text.caption, { color: theme.colors.onBrandPrimary }]}>
{item.label}
</Text>
</View>
))}
</View>
</Animated.View>
{/*
The SIM choice, directly on the wash. It used to be wrapped in a
white card pinned to the foot of the screen; the card was a second
surface doing nothing the cards inside it were not already doing,
and pinning it left a tall empty band above.
*/}
<Animated.View entering={FadeInUp.delay(120).duration(420)} style={styles.simBlock}>
{/* Checked ahead of `detecting`: the AppState listener re-reads the
SIMs every time the app is foregrounded, and a half-typed
number must not be swapped out for a spinner about something
the customer has already chosen to go around. */}
{manualOpen ? (
manualCard
) : detecting ? (
<View
style={[
styles.detecting,
{
backgroundColor: theme.colors.onBrandSurface,
borderColor: theme.colors.onBrandBorder,
borderRadius: theme.radius.md,
},
]}
>
<ActivityIndicator size="small" color={theme.colors.onBrandPrimary} />
<Text
style={[
theme.text.bodySmall,
styles.detectingText,
// On a fill, so solid rather than translucent.
{ color: theme.colors.onBrandPrimary },
]}
>
Reading the SIMs in this phone…
</Text>
</View>
) : blocker ? (
<View
style={[
styles.blocker,
{
backgroundColor: theme.colors.onBrandSurface,
borderColor: theme.colors.onBrandBorder,
borderRadius: theme.radius.lg,
},
]}
>
<View
style={[
styles.blockerIcon,
{ backgroundColor: theme.colors.onBrandSurfaceStrong },
]}
>
<Ionicons
name={needsSettings ? 'settings-outline' : blocker.icon}
size={20}
color={theme.colors.onBrandPrimary}
/>
</View>
<Text
style={[
theme.text.h3,
styles.centreText,
{ color: theme.colors.onBrandPrimary },
]}
>
{needsSettings ? 'Grant it in Settings' : blocker.title}
</Text>
<Text
style={[
theme.text.bodySmall,
styles.centreText,
// On a fill, so solid rather than translucent.
{ color: theme.colors.onBrandPrimary },
]}
>
{needsSettings
? 'Android will not show the permission prompt again for this app. Open Settings, allow Phone, and come back — we will pick it up from there.'
: blocker.text}
</Text>
{/* White fill on the wash: `secondary` here would be a white
button on a white card, which is not the situation any more. */}
<Button
title={needsSettings ? 'Open Settings' : blocker.action}
variant="inverse"
onPress={() => {
void handleBlockerAction();
}}
style={styles.retry}
/>
{manualLink}
</View>
) : (
<>
<SimChooser
sims={sims}
busyId={sendingId}
onSelect={handleSelectSim}
disabled={isLoading}
onBrand
/>
{/* An InfoBanner rather than bare red text: `danger` is a dark
red and would sit at roughly 1.5:1 against the navy. The
banner brings its own light surface, so the message stays
readable wherever it is put. */}
{numberError ? <InfoBanner tone="danger" message={numberError} /> : null}
{/* A line the carrier will not name still has to get a code
somehow, and a customer registering a number that is not in
this phone at all has no row to tap either. */}
{manualLink}
</>
)}
</Animated.View>
{/*
Nothing is pinned to the foot of this screen.
The content is centred in the viewport instead, so the space falls
evenly above and below the SIM cards rather than pooling under them.
An empty band at the bottom of a screen reads as unfinished; the
same amount of space, split, reads as breathing room.
*/}
<View style={styles.footer}>
{/* Only worth offering when a profile is actually stored here —
otherwise it leads to an MPIN screen with nothing to sign in to. */}
{lastProfile ? (
<Pressable onPress={() => navigation.navigate('Login')} hitSlop={6}>
<Text
style={[
theme.text.bodySmall,
styles.footerLink,
{ color: theme.colors.onBrandSecondary },
]}
>
Already registered?{' '}
<Text style={{ color: theme.colors.onBrandPrimary }}>Sign in</Text>
</Text>
</Pressable>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
centreText: {
textAlign: 'center',
},
scroll: {
flexGrow: 1,
// Centred, not top-aligned: with nothing pinned to the foot, the leftover
// height splits evenly above and below the content instead of pooling into
// one empty band under the SIM cards.
justifyContent: 'center',
paddingTop: 14,
paddingBottom: 28,
paddingHorizontal: 22,
gap: 30,
},
back: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
heroBody: { gap: 10 },
brandRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 9,
},
mark: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
heroTitle: {
marginTop: 4,
},
heroSub: {
maxWidth: 340,
},
trustRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 6,
},
trustChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
borderWidth: 1,
paddingVertical: 5,
paddingHorizontal: 10,
},
simBlock: {
gap: 12,
},
detecting: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
paddingVertical: 12,
paddingHorizontal: 14,
},
detectingText: { flex: 1 },
blocker: {
alignItems: 'center',
gap: 8,
borderWidth: 1,
padding: 18,
},
blockerIcon: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 2,
},
retry: {
marginTop: 6,
},
manual: {
gap: 12,
borderWidth: 1,
padding: 18,
},
manualLink: {
textAlign: 'center',
paddingVertical: 10,
},
manualDismiss: {
paddingVertical: 4,
},
footer: {
gap: 4,
},
footerLink: {
textAlign: 'center',
paddingVertical: 11,
},
});
@@ -0,0 +1,282 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useTheme, fontFamily } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useToast } from '../../../hooks/useToast';
import { authActions } from '../store/authSlice';
import { selectRegistration } from '../store/authSelectors';
import {
rememberRegisteredProfile,
startRegistration,
verifyRegistrationOtp,
} from '../store/authThunk';
import { isValidOtp } from '../utils/authValidation';
import { CodeInput } from '../components/CodeInput';
import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'VerifyOtp'>>;
const RESEND_COOLDOWN_SECONDS = 30;
export default function VerifyOtpScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const showApiError = useApiErrorToast();
const registration = useAppSelector(selectRegistration);
const [otp, setOtp] = useState('');
const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS);
const pending = useRef<'verify' | 'resend' | null>(null);
/** The deferred navigation frame — see `afterStateSettles`. */
const navFrame = useRef<number | null>(null);
useEffect(
() => () => {
if (navFrame.current !== null) {
cancelAnimationFrame(navFrame.current);
}
},
[],
);
/**
* Runs a navigation action one frame after the dispatches that sit beside it.
*
* React batches an effect's work into a single commit, and the branches below
* each dispatch *and* move the stack. That one commit then tells Fabric to do
* two incompatible things at once: the dispatch re-renders the Register
* screen still mounted under this one — its footer link, its error row — while
* the reset removes that very subtree. Mounting the result throws "The
* specified child already has a parent", which is a red box in debug and a
* blank screen with no way to sign in once it is dismissed.
*
* The navigator already froze `initialRouteName` against the same collision;
* this is the other half of it. A frame's gap splits the work into two
* commits that each describe one change: the state settles, then the stack
* moves. It is not a race — `pending` is cleared before the frame is
* queued, so nothing re-enters in between.
*/
const afterStateSettles = useCallback((run: () => void) => {
if (navFrame.current !== null) {
cancelAnimationFrame(navFrame.current);
}
navFrame.current = requestAnimationFrame(() => {
navFrame.current = null;
run();
});
}, []);
const isLoading = registration.status === 'loading';
const fieldErrors = registration.error?.fieldErrors;
useEffect(() => {
if (cooldown <= 0) {
return;
}
const timer = setTimeout(() => setCooldown((value) => value - 1), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
useEffect(() => {
if (!pending.current) {
return;
}
if (registration.status === 'succeeded') {
const action = pending.current;
pending.current = null;
if (action === 'resend') {
setOtp('');
setCooldown(RESEND_COOLDOWN_SECONDS);
toast('Code sent', 'success', 'Check your messages for a new code.');
return;
}
/**
* Where verification lands depends on whether this profile owes an MPIN.
*
* `mpinSetupRequired: false` means one is already set — the customer is
* re-binding a device or has reinstalled — so asking them to choose a new
* MPIN would be wrong twice over: the create endpoint refuses it with
* `MPIN_ALREADY_SET`, and offering the choice implies the old one no
* longer works, which sends people to a branch for a reset they do not
* need. They go to sign-in and enter the MPIN they have.
*
* The stack is reset rather than pushed. The setup token is spent either
* way, so every screen behind this one is a dead end — and on the
* already-registered path there is nothing left to go back FOR.
*/
if (!registration.mpinSetupRequired) {
dispatch(authActions.resetRegistration());
toast(
'You are already registered',
'info',
'This device is set up. Sign in with your existing MPIN.',
);
afterStateSettles(() => navigation.reset({ index: 0, routes: [{ name: 'Login' }] }));
return;
}
// The setup token is single use and lasts five minutes, so MPIN creation
// follows immediately rather than sitting behind another screen.
if (registration.setupToken) {
navigation.navigate('CreateMpin');
}
return;
}
if (registration.status === 'failed' && registration.error) {
pending.current = null;
showApiError(registration.error);
setOtp('');
const { code } = registration.error;
// Out of attempts, or the window closed — either way the OTP on this
// screen can never succeed, so start over rather than let them keep trying.
if (code === 'OTP_ATTEMPTS_EXCEEDED' || code === 'OTP_EXPIRED') {
dispatch(authActions.resetRegistration());
afterStateSettles(() => navigation.navigate('Register'));
return;
}
// Same profile, already set up — sign-in is the only step left. Recorded
// before the reset clears the number, so sign-in asks for the MPIN
// instead of reading a blank profile as "never registered" and sending
// them back to the start of registration.
if (code === 'ALREADY_REGISTERED' || code === 'MPIN_ALREADY_SET') {
void dispatch(
rememberRegisteredProfile({
orgId: registration.orgId,
mobileNumber: registration.mobileNumber,
}),
);
dispatch(authActions.resetRegistration());
afterStateSettles(() => navigation.navigate('Login'));
}
}
}, [afterStateSettles, dispatch, navigation, registration, showApiError, toast]);
const handleVerify = () => {
if (!isValidOtp(otp) || isLoading) {
return;
}
pending.current = 'verify';
void dispatch(verifyRegistrationOtp({ otp }));
};
const handleResend = () => {
if (cooldown > 0 || isLoading) {
return;
}
pending.current = 'resend';
void dispatch(
startRegistration({
mobileNumber: registration.mobileNumber,
orgId: registration.orgId,
}),
);
};
const maskedMobile = registration.mobileNumber
? `••••••${registration.mobileNumber.slice(-4)}`
: '';
return (
<ScreenShell
title="Enter the code"
subtitle={`Sent to ${maskedMobile}`}
withTabBar={false}
contentContainerStyle={styles.container}
>
<Card style={styles.formCard}>
<CodeInput
label="6-digit code"
value={otp}
onChangeText={setOtp}
length={6}
autoFocus
editable={!isLoading}
onSubmit={handleVerify}
error={fieldErrors?.otp}
/>
<Pressable onPress={handleResend} disabled={cooldown > 0 || isLoading} hitSlop={8}>
<Text
style={[
styles.resendText,
{ color: cooldown > 0 ? theme.colors.textMuted : theme.colors.primary },
]}
>
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
</Text>
</Pressable>
</Card>
{registration.accountsLinked > 0 && (
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.success}0D` }]}>
<Ionicons name="checkmark-circle-outline" size={18} color={theme.colors.success} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
{registration.accountsLinked === 1
? '1 account found at your bank and ready to link.'
: `${registration.accountsLinked} accounts found at your bank and ready to link.`}
</Text>
</View>
)}
<Button
title="Verify"
onPress={handleVerify}
disabled={!isValidOtp(otp) || isLoading}
loading={isLoading}
style={styles.submitButton}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
flexGrow: 1,
},
formCard: {
padding: 16,
gap: 16,
},
resendText: {
fontSize: 14,
fontFamily: fontFamily.semibold,
textAlign: 'center',
},
noteCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
padding: 14,
borderRadius: 12,
},
noteText: {
flex: 1,
fontSize: 13,
lineHeight: 18,
},
submitButton: {
marginTop: 'auto',
height: 54,
},
});
+141 -86
View File
@@ -1,96 +1,151 @@
import type { AuthTokens, UserProfile } from '../../../types/app';
import { demoUser } from '../../../constants/mockData';
import { endpoints } from '../../../services/api/endpoints';
import { env } from '../../../config/env';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { resolveWithMock } from '../../../services/api/serviceHelpers';
import { isCbsApiError } from '../../../services/api/cbsErrors';
import { endpoints } from '../../../services/api/endpoints';
import { deviceIdentity } from '../../../services/security/deviceIdentity';
import { mpinCrypto } from '../../../services/security/mpinCrypto';
import type {
CreateMpinResult,
LoginResult,
SessionIdentity,
StartRegistrationResult,
VerifyOtpResult,
} from '../../../types/cbs';
interface LoginRequest {
phone: string;
password: string;
}
/**
* `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks
* like from the client — the server will not say more, because saying more
* would leak whether the profile exists. So drop the cached key, re-fetch, and
* try exactly once more. A second failure is a real problem worth surfacing.
*/
const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
try {
return await call();
} catch (error) {
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
mpinCrypto.invalidate();
return call();
}
interface LoginResponse {
challengeId: string;
phone: string;
}
throw error;
}
};
interface VerifyOtpRequest {
challengeId: string;
otp: string;
phone: string;
}
export const authService = {
/**
* Looks the customer up in their bank's core system, links every account it
* returns and sends an OTP to the handset.
*/
async startRegistration(mobileNumber: string, orgId: string = env.orgId) {
const response = await apiClient.post<StartRegistrationResult>(
endpoints.registration.start,
{ orgId, mobileNumber },
);
interface VerifyOtpResponse {
verified: true;
phone: string;
}
return response.data;
},
interface SetupMpinRequest {
phone: string;
mpin: string;
}
/**
* Verifies the OTP, binds the profile to this device and allocates an MMID to
* every linked account. Returns the single-use, five-minute setup token that
* MPIN creation requires.
*/
async verifyRegistrationOtp({
mobileNumber,
otp,
orgId = env.orgId,
}: {
mobileNumber: string;
otp: string;
orgId?: string;
}) {
const deviceId = await deviceIdentity.get();
interface SetupMpinResponse {
tokens: AuthTokens;
user: UserProfile;
}
const response = await apiClient.post<VerifyOtpResult>(endpoints.registration.verifyOtp, {
orgId,
mobileNumber,
otp,
deviceId,
});
const createDemoTokens = (): AuthTokens => ({
accessToken: 'demo-access-token',
refreshToken: 'demo-refresh-token',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
});
return response.data;
},
export const authApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation<LoginResponse, LoginRequest>({
queryFn: ({ phone }) =>
resolveWithMock({
mockData: {
challengeId: 'otp_challenge_001',
phone,
},
request: async () => {
const response = await apiClient.post<LoginResponse>(endpoints.auth.login, { phone });
return response.data;
},
}),
}),
verifyOtp: builder.mutation<VerifyOtpResponse, VerifyOtpRequest>({
queryFn: ({ phone }) =>
resolveWithMock({
mockData: {
verified: true,
phone,
},
request: async () => {
const response = await apiClient.post<VerifyOtpResponse>(endpoints.auth.verifyOtp, {
phone,
});
return response.data;
},
}),
}),
setupMpin: builder.mutation<SetupMpinResponse, SetupMpinRequest>({
queryFn: () =>
resolveWithMock({
mockData: () => ({
tokens: createDemoTokens(),
user: demoUser,
}),
request: async () => {
const response = await apiClient.post<SetupMpinResponse>(endpoints.auth.setupMpin);
return response.data;
},
}),
invalidatesTags: ['Auth', 'User'],
}),
}),
});
/**
* Creates the MPIN and completes registration. There is deliberately no
* orgId and no mobile number in the request — both come from the setup token,
* which is what stops a caller setting someone else's MPIN. The deviceId must
* match the one sent at verify-otp.
*/
async createMpin({ setupToken, mpin }: { setupToken: string; mpin: string }) {
return withKeyRetry(async () => {
const deviceId = await deviceIdentity.get();
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
export const {
useLoginMutation,
useVerifyOtpMutation,
useSetupMpinMutation,
} = authApi;
const response = await apiClient.post<CreateMpinResult>(endpoints.registration.mpin, {
setupToken,
deviceId,
encryptedMpin,
});
return response.data;
});
},
/**
* Verifies the MPIN and opens a session. The MPIN is encrypted exactly as it
* was at setup — same key, same payload shape.
*/
async login({
mobileNumber,
mpin,
orgId = env.orgId,
setupToken,
}: {
mobileNumber: string;
mpin: string;
orgId?: string;
/** See `LoginRequest.setupToken` — a device re-bind, and only that. */
setupToken?: string | null;
}) {
return withKeyRetry(async () => {
const deviceId = await deviceIdentity.get();
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
const response = await apiClient.post<LoginResult>(endpoints.auth.login, {
orgId,
mobileNumber,
deviceId,
encryptedMpin,
// Spread rather than set: an ordinary sign-in must not carry the key at
// all, and `setupToken: undefined` is a key once this goes through
// JSON.stringify on some clients.
...(setupToken ? { setupToken } : {}),
});
return response.data;
});
},
/** Who the current session belongs to. Doubles as a keep-alive. */
async getIdentity() {
const response = await apiClient.get<SessionIdentity>(endpoints.auth.me);
return response.data;
},
/**
* Revokes the session immediately. The endpoint always answers 200, even for
* a token that was already dead — a different answer would tell a caller
* whether a token they hold is live. Nothing that happens here may block the
* local sign-out, so every failure is swallowed: the customer asked to be
* signed out, and a network error must not leave them signed in.
*/
async logout() {
try {
await apiClient.post(endpoints.auth.logout);
} catch {
// Intentionally ignored. The local session is cleared regardless.
}
},
};
+36 -2
View File
@@ -3,6 +3,40 @@ import type { RootState } from '../../../app/store';
export const selectAuthState = (state: RootState) => state.auth;
export const selectIsAuthenticated = (state: RootState) => state.auth.isAuthenticated;
export const selectAuthHydrating = (state: RootState) => state.auth.isHydrating;
export const selectPendingPhone = (state: RootState) => state.auth.pendingPhone;
export const selectChallengeId = (state: RootState) => state.auth.challengeId;
export const selectAuthRole = (state: RootState) => state.auth.role;
export const selectAuthSession = (state: RootState) => state.auth.session;
export const selectAuthMethod = (state: RootState) => state.auth.authMethod;
/** True only for a session opened against the live CBS backend. */
export const selectHasLiveSession = (state: RootState) =>
Boolean(state.auth.session?.live);
export const selectSignInStatus = (state: RootState) => state.auth.signInStatus;
export const selectSignInError = (state: RootState) => state.auth.signInError;
export const selectMpinLocked = (state: RootState) => state.auth.mpinLocked;
export const selectLastProfile = (state: RootState) => state.auth.lastProfile;
export const selectBiometricReady = (state: RootState) => state.auth.biometricReady;
export const selectRegistration = (state: RootState) => state.auth.registration;
export const selectRegistrationMobile = (state: RootState) =>
state.auth.registration.mobileNumber;
export const selectRegistrationStatus = (state: RootState) => state.auth.registration.status;
export const selectRegistrationError = (state: RootState) => state.auth.registration.error;
export const selectCustomerName = (state: RootState) => state.auth.session?.fullName ?? '';
/**
* The signed-in customer's own mobile number, from the session.
*
* This is profile data, not account data — it comes back with the session
* rather than from `/accounts`, which carries no mobile number.
*/
export const selectSessionMobile = (state: RootState) =>
state.auth.session?.mobileNumber;
/**
* True once the remembered profile has actually been read off the Keystore.
* Until then a null `lastProfile` means "unknown", not "never registered".
*/
export const selectSignInContextLoaded = (state: RootState) =>
state.auth.signInContextLoaded;
+260 -39
View File
@@ -1,80 +1,301 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { createSlice, isAnyOf, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthTokens } from '../../../types/app';
import { bootstrapAuthSession, logoutSession } from './authThunk';
import { env } from '../../../config/env';
import type { ApiError } from '../../../types/api';
import type { AuthSession, LoginRole } from '../../../types/app';
import type { AuthState } from '../types';
import {
bootstrapAuthSession,
completeMpinSetup,
disableBiometricSignIn,
loadSignInContext,
logoutSession,
rememberRegisteredProfile,
signIn,
signInAsAgent,
signInWithBiometrics,
startRegistration,
verifyRegistrationOtp,
} from './authThunk';
import type { LoginRole } from '../../../types/app';
interface AuthState {
isHydrating: boolean;
isAuthenticated: boolean;
pendingPhone: string;
challengeId: string | null;
tokens: AuthTokens | null;
authMethod: 'otp' | 'biometric' | null;
role: LoginRole;
}
const emptyRegistration = () => ({
orgId: env.orgId,
mobileNumber: '',
accountsLinked: 0,
mmidsAssigned: 0,
setupToken: null,
otpSent: false,
mpinSetupRequired: true,
status: 'idle' as const,
error: null,
});
const initialState: AuthState = {
isHydrating: true,
isAuthenticated: false,
pendingPhone: '',
challengeId: null,
tokens: null,
authMethod: null,
session: null,
role: 'User',
authMethod: null,
registration: emptyRegistration(),
signInStatus: 'idle',
signInError: null,
mpinLocked: false,
lastProfile: null,
rebindToken: null,
biometricReady: false,
signInContextLoaded: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setPendingPhone(state, action: PayloadAction<string>) {
state.pendingPhone = action.payload;
setRole(state, action: PayloadAction<LoginRole>) {
state.role = action.payload;
},
setChallengeId(state, action: PayloadAction<string>) {
state.challengeId = action.payload;
/** Clears a sign-in error so a fresh keystroke does not sit under a stale one. */
clearSignInError(state) {
state.signInError = null;
},
authenticate(state, action: PayloadAction<AuthTokens>) {
state.tokens = action.payload;
state.isAuthenticated = true;
state.isHydrating = false;
state.role = action.payload.role || 'User';
},
setAuthMethod(state, action: PayloadAction<'otp' | 'biometric'>) {
state.authMethod = action.payload;
resetRegistration(state) {
state.registration = emptyRegistration();
},
resetAuthFlow(state) {
state.challengeId = null;
state.pendingPhone = '';
state.registration = emptyRegistration();
state.signInStatus = 'idle';
state.signInError = null;
},
/**
* The customer must visit a branch to clear a lock, so this only exists for
* leaving the locked screen — it does not pretend to unlock anything.
*/
dismissMpinLock(state) {
state.mpinLocked = false;
},
},
extraReducers: (builder) => {
builder
/* Bootstrap */
.addCase(bootstrapAuthSession.pending, (state) => {
state.isHydrating = true;
})
.addCase(bootstrapAuthSession.fulfilled, (state, action) => {
state.tokens = action.payload;
state.isAuthenticated = Boolean(action.payload);
const { session, lastProfile, biometricReady } = action.payload;
state.session = session;
state.isAuthenticated = Boolean(session);
state.role = session?.role ?? 'User';
// A restored session cannot say how it was opened; what matters here is
// only whether it is a live CBS session or the local agent one.
state.authMethod = session ? (session.live ? 'mpin' : 'local') : null;
state.isHydrating = false;
state.role = action.payload?.role || 'User';
state.lastProfile = lastProfile;
state.biometricReady = biometricReady;
state.signInContextLoaded = true;
})
.addCase(bootstrapAuthSession.rejected, (state) => {
state.isHydrating = false;
state.isAuthenticated = false;
state.session = null;
state.role = 'User';
state.signInContextLoaded = true;
})
/* Registration — mobile number */
.addCase(startRegistration.pending, (state, action) => {
state.registration = {
...emptyRegistration(),
mobileNumber: action.meta.arg.mobileNumber,
orgId: action.meta.arg.orgId ?? env.orgId,
status: 'loading',
};
})
.addCase(startRegistration.fulfilled, (state, action) => {
state.registration.status = 'succeeded';
state.registration.otpSent = true;
state.registration.mobileNumber = action.payload.mobileNumber;
state.registration.orgId = action.payload.orgId;
state.registration.accountsLinked = action.payload.accountsLinked;
state.registration.error = null;
})
.addCase(startRegistration.rejected, (state, action) => {
state.registration.status = 'failed';
state.registration.error = action.payload ?? null;
})
/* Registration — OTP */
.addCase(verifyRegistrationOtp.pending, (state) => {
state.registration.status = 'loading';
state.registration.error = null;
})
.addCase(verifyRegistrationOtp.fulfilled, (state, action) => {
state.registration.status = 'succeeded';
state.registration.setupToken = action.payload.setupToken;
state.registration.mmidsAssigned = action.payload.mmidsAssigned;
state.registration.mpinSetupRequired = action.payload.mpinSetupRequired;
/**
* A profile that already has an MPIN is a device this customer is
* re-binding, so record it as the last profile now.
*
* Without this the sign-in screen sees no stored profile and bounces
* straight back to registration — the OTP screen would send them to
* sign-in, sign-in would send them to Register, and the loop never
* settles. Recording it here is also just true: verify-otp has bound
* the profile to this device, which is exactly what `lastProfile`
* means.
*
* `fullName` is deliberately absent — verify-otp does not return one,
* and the field is optional precisely so it can be filled in later,
* when a session actually opens and the name is known.
*/
if (!action.payload.mpinSetupRequired) {
state.lastProfile = {
orgId: state.registration.orgId,
mobileNumber: state.registration.mobileNumber,
};
// No MPIN to create, so the token's only remaining use is the sign-in
// this customer is about to do. Held outside `registration` so the
// OTP screen's `resetRegistration()` cannot take it with it.
state.rebindToken = action.payload.setupToken;
}
})
.addCase(verifyRegistrationOtp.rejected, (state, action) => {
state.registration.status = 'failed';
state.registration.error = action.payload ?? null;
})
/* Registration — MPIN, which also opens the session */
.addCase(completeMpinSetup.pending, (state) => {
state.registration.status = 'loading';
state.registration.error = null;
})
.addCase(completeMpinSetup.rejected, (state, action) => {
state.registration.status = 'failed';
state.registration.error = action.payload ?? null;
})
/* Sign in */
.addCase(signInAsAgent.fulfilled, (state, action) => {
applySession(state, action.payload, 'local');
})
.addCase(disableBiometricSignIn.fulfilled, (state) => {
state.biometricReady = false;
})
/**
* Handled on `pending` rather than `fulfilled` on purpose. The screens
* that dispatch this navigate to sign-in on the very next line, and
* sign-in decides whether to bounce back to registration from
* `lastProfile`. Waiting for the Keystore write would let that decision
* be made against a profile that is not there yet — the write takes tens
* of milliseconds on Android, which is plenty for the screen to mount.
* `pending` runs synchronously inside the dispatch, so it cannot race.
*/
.addCase(rememberRegisteredProfile.pending, (state, action) => {
const { orgId, mobileNumber } = action.meta.arg;
state.lastProfile = {
orgId,
mobileNumber,
// Keep a name we already hold for this same number; drop one that
// belonged to whoever was signed in here before.
fullName:
state.lastProfile?.mobileNumber === mobileNumber
? state.lastProfile.fullName
: undefined,
};
state.signInContextLoaded = true;
})
// Re-read after a logout wiped the store, so the sign-in screen still
// knows who was here and whether biometrics are set up.
.addCase(loadSignInContext.fulfilled, (state, action) => {
state.lastProfile = action.payload.lastProfile;
state.biometricReady = action.payload.biometricReady;
state.signInContextLoaded = true;
})
/* Logout */
.addCase(logoutSession.fulfilled, (state) => {
state.tokens = null;
state.session = null;
state.isAuthenticated = false;
state.pendingPhone = '';
state.challengeId = null;
state.authMethod = null;
// Logout wipes the whole store, so this reducer runs against
// `initialState` — where `isHydrating` is true. Left alone, the app
// would sit on the bootstrap loader instead of showing sign-in.
state.isHydrating = false;
state.role = 'User';
state.authMethod = null;
state.registration = emptyRegistration();
state.signInStatus = 'idle';
state.signInError = null;
})
// Matchers must follow every `addCase`, which is why the two sign-in
// paths that share reducers land here rather than above.
.addMatcher(isAnyOf(signIn.pending, signInWithBiometrics.pending), (state) => {
state.signInStatus = 'loading';
state.signInError = null;
})
.addMatcher(
isAnyOf(signIn.fulfilled, signInWithBiometrics.fulfilled, completeMpinSetup.fulfilled),
(state, action) => {
const method =
action.type === signInWithBiometrics.fulfilled.type ? 'biometric' : 'mpin';
applySession(state, action.payload as AuthSession, method);
state.registration = emptyRegistration();
// Spent. Cleared here rather than on `rejected` so a mistyped MPIN
// can be retried against the same token — a wrong MPIN does not
// consume it, and the server expires it on its own after five
// minutes either way.
state.rebindToken = null;
// This sign-in is also the only moment the MPIN is known good, so it
// is where opting into biometrics takes effect.
const arg = action.meta.arg as { rememberForBiometrics?: boolean } | undefined;
if (arg?.rememberForBiometrics) {
state.biometricReady = true;
}
},
)
.addMatcher(isAnyOf(signIn.rejected, signInWithBiometrics.rejected), (state, action) => {
const error = (action.payload as ApiError | undefined) ?? null;
state.signInStatus = 'failed';
state.signInError = error;
// Three wrong MPINs lock the profile and no in-app path clears it, so
// the app stops offering the form and says where to go instead.
if (error?.code === 'MPIN_LOCKED') {
state.mpinLocked = true;
}
});
},
});
function applySession(
state: AuthState,
session: AuthSession,
method: NonNullable<AuthState['authMethod']>,
) {
state.session = session;
state.isAuthenticated = true;
state.isHydrating = false;
state.role = session.role;
state.authMethod = method;
state.signInStatus = 'succeeded';
state.signInError = null;
state.mpinLocked = false;
state.lastProfile = {
orgId: session.orgId,
mobileNumber: session.mobileNumber,
fullName: session.fullName,
};
}
export const authActions = authSlice.actions;
export const authReducer = authSlice.reducer;
+473 -6
View File
@@ -1,13 +1,480 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { appConfig } from '../../../config/appConfig';
import { env } from '../../../config/env';
import { toApiError } from '../../../services/api/serviceHelpers';
import { deviceIdentity } from '../../../services/security/deviceIdentity';
import { sessionManager } from '../../../services/security/sessionManager';
import { purgeUserData } from '../../../services/storage/appDataPurge';
import { secureStorage } from '../../../services/storage/secureStorage';
import type { ApiError } from '../../../types/api';
import type { AuthSession } from '../../../types/app';
import type { LoginResult, StartRegistrationResult, VerifyOtpResult } from '../../../types/cbs';
import { authService } from '../services/authApi';
import type { AuthThunkState } from '../types';
export const bootstrapAuthSession = createAsyncThunk('auth/bootstrapSession', async () => {
const tokens = await secureStorage.getSession();
return tokens;
interface ThunkConfig {
state: AuthThunkState;
rejectValue: ApiError;
}
/**
* Adopt a freshly opened session: persist it, hand the server's own two
* timeouts to the session manager, and remember the profile so the sign-in
* screen can prefill it next time.
*/
const adoptSession = async (session: AuthSession) => {
await secureStorage.saveSession(session);
await secureStorage.saveLastProfile({
orgId: session.orgId,
mobileNumber: session.mobileNumber,
fullName: session.fullName,
});
sessionManager.configure({
idleTimeoutSeconds: session.idleTimeoutSeconds,
expiresAt: session.expiresAt,
});
return session;
};
const toSession = (
result: LoginResult,
{ orgId, mobileNumber }: { orgId: string; mobileNumber: string },
): AuthSession => ({
sessionToken: result.sessionToken,
expiresAt: result.expiresAt,
idleTimeoutSeconds: result.idleTimeoutSeconds,
orgId,
mobileNumber,
fullName: result.fullName,
role: 'User',
live: true,
});
export const logoutSession = createAsyncThunk('auth/logoutSession', async () => {
await secureStorage.clearSession();
return true;
/* ── Registration ─────────────────────────────────────────────────────────── */
export const startRegistration = createAsyncThunk<
StartRegistrationResult & { mobileNumber: string; orgId: string },
{ mobileNumber: string; orgId?: string },
ThunkConfig
>('auth/startRegistration', async ({ mobileNumber, orgId = env.orgId }, { rejectWithValue }) => {
try {
// Minted here rather than at the first call that needs it, so the id the
// profile gets bound to exists before any of the registration steps run.
await deviceIdentity.get();
const result = await authService.startRegistration(mobileNumber, orgId);
return { ...result, mobileNumber, orgId };
} catch (error) {
return rejectWithValue(toApiError(error));
}
});
export const verifyRegistrationOtp = createAsyncThunk<
VerifyOtpResult,
{ otp: string },
ThunkConfig
>('auth/verifyRegistrationOtp', async ({ otp }, { getState, rejectWithValue }) => {
const { mobileNumber, orgId } = getState().auth.registration;
try {
const result = await authService.verifyRegistrationOtp({ mobileNumber, otp, orgId });
/**
* A profile that already holds an MPIN goes to the Keystore right away.
*
* The reducer puts the same profile into state, which is enough to route
* this session — but only the Keystore copy survives a relaunch, and this
* customer is about to be asked for an MPIN they may not remember. If they
* close the app at that prompt, the stored profile is what brings them back
* to sign-in instead of dumping them into registration to do the OTP again.
*
* Not awaited against the response: a Keystore write costs tens of
* milliseconds on Android and nothing here depends on it. A failed write
* costs the shortcut at next launch, not this registration, so it is
* swallowed rather than failing a verification the server already accepted.
*/
if (!result.mpinSetupRequired) {
void secureStorage.saveLastProfile({ orgId, mobileNumber }).catch(() => {});
}
return result;
} catch (error) {
return rejectWithValue(toApiError(error));
}
});
/**
* Creates the MPIN and, because the MPIN is still in hand, opens the session
* straight away — so registration lands the customer in the app rather than
* back on a sign-in form typing what they just chose.
*/
export const completeMpinSetup = createAsyncThunk<
AuthSession,
{ mpin: string; rememberForBiometrics?: boolean },
ThunkConfig
>(
'auth/completeMpinSetup',
async ({ mpin, rememberForBiometrics = false }, { getState, rejectWithValue }) => {
const { mobileNumber, orgId, setupToken } = getState().auth.registration;
if (!setupToken) {
return rejectWithValue({
status: 409,
message: 'Your setup window has closed. Start registration again.',
code: 'OTP_NOT_VERIFIED',
});
}
try {
await authService.createMpin({ setupToken, mpin });
const result = await authService.login({ mobileNumber, mpin, orgId });
if (rememberForBiometrics) {
await secureStorage.saveBiometricMpin(mpin);
await secureStorage.setBiometricEnabled(true);
}
return adoptSession(toSession(result, { orgId, mobileNumber }));
} catch (error) {
return rejectWithValue(toApiError(error));
}
},
);
/* ── Sign in ──────────────────────────────────────────────────────────────── */
export const signIn = createAsyncThunk<
AuthSession,
{ mobileNumber: string; mpin: string; orgId?: string; rememberForBiometrics?: boolean },
ThunkConfig
>(
'auth/signIn',
async (
{ mobileNumber, mpin, orgId = env.orgId, rememberForBiometrics = false },
{ getState, rejectWithValue },
) => {
try {
// Null on an ordinary sign-in, which is the overwhelming majority of
// them; set only when verify-otp has just answered
// `mpinSetupRequired: false` and sent the customer here to use the MPIN
// they already have.
const { rebindToken } = getState().auth;
const result = await authService.login({
mobileNumber,
mpin,
orgId,
setupToken: rebindToken,
});
if (rememberForBiometrics) {
await secureStorage.saveBiometricMpin(mpin);
await secureStorage.setBiometricEnabled(true);
}
return adoptSession(toSession(result, { orgId, mobileNumber }));
} catch (error) {
return rejectWithValue(toApiError(error));
}
},
);
/**
* The API opens a session only against an encrypted MPIN — there is no refresh
* token to trade in — so biometric sign-in unlocks the MPIN the customer chose
* to store behind the device prompt and then signs in with it normally.
*/
export const signInWithBiometrics = createAsyncThunk<AuthSession, void, ThunkConfig>(
'auth/signInWithBiometrics',
async (_, { rejectWithValue }) => {
const profile = await secureStorage.getLastProfile();
if (!profile) {
return rejectWithValue({
status: 409,
message: 'Sign in with your MPIN once to enable biometrics.',
code: 'MPIN_NOT_SET',
});
}
const mpin = await secureStorage.getBiometricMpin(
appConfig.security.biometricPromptTitle,
);
if (!mpin) {
return rejectWithValue({
status: 401,
message: 'Biometric sign-in was cancelled.',
code: 'BIOMETRIC_CANCELLED',
});
}
try {
const result = await authService.login({
mobileNumber: profile.mobileNumber,
mpin,
orgId: profile.orgId,
});
return adoptSession(
toSession(result, { orgId: profile.orgId, mobileNumber: profile.mobileNumber }),
);
} catch (error) {
const apiError = toApiError(error);
// A stored MPIN that no longer works is worse than no stored MPIN — it
// would burn an attempt against the three-strike lock on every try.
if (apiError.code === 'INVALID_CREDENTIALS' || apiError.code === 'MPIN_LOCKED') {
await secureStorage.setBiometricEnabled(false);
}
return rejectWithValue(apiError);
}
},
);
/**
* The field-agent application has no CBS backend behind it yet, so it runs on a
* local session rather than pretending to have authenticated against the bank.
*/
export const signInAsAgent = createAsyncThunk<AuthSession, { mobileNumber: string }>(
'auth/signInAsAgent',
async ({ mobileNumber }) => {
const session: AuthSession = {
sessionToken: 'local-agent-session',
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
idleTimeoutSeconds: 300,
orgId: env.orgId,
mobileNumber,
fullName: 'Field Agent',
role: 'Agent',
live: false,
};
await secureStorage.saveSession(session);
sessionManager.configure({
idleTimeoutSeconds: session.idleTimeoutSeconds,
expiresAt: session.expiresAt,
});
return session;
},
);
/**
* Turning biometric sign-in off is safe from anywhere: drop the stored MPIN and
* the flag. Turning it *on* is not, and deliberately has no thunk — verifying
* an MPIN means opening a session, and one active session per profile would
* revoke the session the customer is currently using. So it can only be enabled
* at the moment the MPIN is already known good: at sign-in, or at MPIN setup.
*/
export const disableBiometricSignIn = createAsyncThunk<void, void>(
'auth/disableBiometricSignIn',
async () => {
await secureStorage.setBiometricEnabled(false);
},
);
/* ── Session lifecycle ────────────────────────────────────────────────────── */
export const bootstrapAuthSession = createAsyncThunk<{
session: AuthSession | null;
lastProfile: Awaited<ReturnType<typeof secureStorage.getLastProfile>>;
biometricReady: boolean;
}>('auth/bootstrapSession', async () => {
// Established before anything can need it, so no call ever races the mint.
await deviceIdentity.get();
const [stored, lastProfile, biometricEnabled, biometricStored] = await Promise.all([
secureStorage.getSession(),
secureStorage.getLastProfile(),
secureStorage.getBiometricEnabled(),
secureStorage.hasBiometricMpin(),
]);
const biometricReady = Boolean(biometricEnabled && biometricStored && lastProfile);
if (!stored) {
return { session: null, lastProfile, biometricReady };
}
/**
* Policy: every launch asks for the MPIN, so a stored session is dropped
* rather than resumed. `lastProfile` still comes back, which is what lets the
* sign-in screen ask for an MPIN alone instead of the mobile number too.
*
* The token is only deleted locally, not revoked. Signing in again opens a
* new session and the backend allows one per profile, so the old one dies
* then anyway — and a network call on the launch path would hang the splash
* whenever the bank is unreachable. Deleting our only copy is what makes it
* unusable in the meantime.
*/
if (appConfig.security.requireMpinOnLaunch) {
await secureStorage.clearSession();
sessionManager.clear();
return { session: null, lastProfile, biometricReady };
}
// A token past its absolute expiry is dead no matter what; do not spend a
// round trip proving it.
if (Date.parse(stored.expiresAt) <= Date.now()) {
await secureStorage.clearSession();
return { session: null, lastProfile, biometricReady };
}
if (!stored.live) {
sessionManager.configure({
idleTimeoutSeconds: stored.idleTimeoutSeconds,
expiresAt: stored.expiresAt,
});
return { session: stored, lastProfile, biometricReady };
}
try {
// Confirms the token against the server rather than trusting what we
// stored, and refreshes the idle timer on the way through.
const identity = await authService.getIdentity();
const session: AuthSession = {
...stored,
fullName: identity.fullName,
orgId: identity.orgId,
mobileNumber: identity.mobileNumber,
expiresAt: identity.expiresAt,
};
await secureStorage.saveSession(session);
sessionManager.configure({
idleTimeoutSeconds: session.idleTimeoutSeconds,
expiresAt: session.expiresAt,
});
return { session, lastProfile, biometricReady };
} catch {
// Signing in elsewhere signs this device out — one active session per
// profile — so a dead token here is expected, not exceptional.
await secureStorage.clearSession();
return { session: null, lastProfile, biometricReady };
}
});
/**
* Just the sign-in context: the remembered profile and whether biometrics are
* set up. Logout wipes all Redux state, and these two live in the Keystore, so
* they have to be re-read — but without `bootstrapAuthSession`'s hydrating
* flag, which would blank the app behind a loader on the way to sign-in.
*/
export const loadSignInContext = createAsyncThunk<{
lastProfile: Awaited<ReturnType<typeof secureStorage.getLastProfile>>;
biometricReady: boolean;
}>('auth/loadSignInContext', async () => {
const [lastProfile, biometricEnabled, biometricStored] = await Promise.all([
secureStorage.getLastProfile(),
secureStorage.getBiometricEnabled(),
secureStorage.hasBiometricMpin(),
]);
return {
lastProfile,
biometricReady: Boolean(biometricEnabled && biometricStored && lastProfile),
};
});
/**
* Records a profile the server has just told us is already registered on this
* device, at a point where there is no session to learn it from.
*
* `ALREADY_REGISTERED` and `MPIN_ALREADY_SET` both mean the same thing to the
* app: stop asking for an OTP, ask for the MPIN. The only way a screen can act
* on that is to send the customer to sign-in — and sign-in reads `lastProfile`
* to decide whether this device has ever completed registration. With nothing
* stored it sends them straight back to registration, which answers
* `ALREADY_REGISTERED` again. That is a loop with no way out of it, and it is
* exactly the customer who has reinstalled the app who falls into it.
*
* `fullName` is deliberately absent: no registration response carries one, and
* the field is optional so it can be filled in the first time a session opens.
*/
export const rememberRegisteredProfile = createAsyncThunk<
void,
{ orgId: string; mobileNumber: string }
>('auth/rememberRegisteredProfile', async ({ orgId, mobileNumber }) => {
await secureStorage.saveLastProfile({ orgId, mobileNumber });
});
/** Keep-alive. Also the cheapest way to notice a session revoked elsewhere. */
export const refreshSessionIdentity = createAsyncThunk<void, void, ThunkConfig>(
'auth/refreshSessionIdentity',
async (_, { rejectWithValue }) => {
try {
await authService.getIdentity();
} catch (error) {
return rejectWithValue(toApiError(error));
}
},
);
/**
* Why the session is ending.
*
* It decides how much of the handset is wiped, so the two are not the same
* event dressed differently. A customer who taps Log out is done with this
* phone and everything of theirs comes off it; an idle timeout or a 401 is the
* app protecting a session they are in the middle of, and taking their
* registration with it would mean an OTP and a fresh device bind to get back
* into an account they never left.
*/
export type LogoutReason = 'user' | 'expired' | 'unauthorized';
/**
* Ends the session, and on a deliberate sign-out leaves nothing of the
* customer behind.
*
* The reason defaults to `user`, which is the wiping path. That direction is
* chosen on purpose: a call site added later that says nothing clears too much
* rather than too little, and the two places that must not wipe say so out
* loud where anyone reading them can see why.
*
* The local clear is unconditional either way. Revoking server-side is
* attempted first and allowed to fail — a handset with no signal must still be
* able to sign out, and a session the server keeps alive until its own expiry
* is a far smaller problem than one whose MPIN and mobile number stay on a
* phone the customer has just handed to someone else.
*
* Redux is separately reset to `undefined` by `rootReducer` on this thunk's
* `fulfilled` action, whatever the reason. Store and storage together are the
* whole of what the app held; see `purgeUserData` for what survives and why.
*/
export const logoutSession = createAsyncThunk<boolean, LogoutReason | void>(
'auth/logoutSession',
async (reason) => {
try {
const session = await secureStorage.getSession();
if (session?.live) {
await authService.logout();
}
} catch {
// Reading or revoking may fail; clearing locally must not.
}
if (reason === undefined || reason === 'user') {
await purgeUserData();
} else {
// The session ends; the registration does not. Same as it has always
// been on these two paths.
await secureStorage.clearSession();
sessionManager.clear();
}
return true;
},
);
+78 -1
View File
@@ -1 +1,78 @@
export type { AuthTokens, UserProfile } from '../../../types/app';
import type { ApiError } from '../../../types/api';
import type { AsyncStatus, AuthSession, LoginRole } from '../../../types/app';
import type { LastProfile } from '../../../services/storage/secureStorage';
export type { AuthSession, LoginRole, UserProfile } from '../../../types/app';
export type { LastProfile } from '../../../services/storage/secureStorage';
/** How the current session was opened. */
export type AuthMethod = 'mpin' | 'biometric' | 'local';
/**
* The three registration calls in flight. `setupToken` is single-use and lasts
* five minutes, so it lives here only for the hop between OTP verification and
* MPIN creation and is dropped the moment either finishes.
*/
export interface RegistrationState {
orgId: string;
mobileNumber: string;
accountsLinked: number;
mmidsAssigned: number;
setupToken: string | null;
otpSent: boolean;
/**
* Whether this profile still owes an MPIN, as reported by verify-otp.
*
* False means the customer already set one — they are re-registering a
* device, or reinstalling — so the app must ask for the MPIN they have
* rather than offering to create a second one. The create-MPIN endpoint
* would refuse it anyway (`MPIN_ALREADY_SET`), so sending them there is a
* dead end dressed up as a step.
*
* Defaults true: only a verify-otp response can lower it, and if the field
* were ever missing, offering to create is the state registration has
* always assumed.
*/
mpinSetupRequired: boolean;
status: AsyncStatus;
error: ApiError | null;
}
export interface AuthState {
isHydrating: boolean;
isAuthenticated: boolean;
session: AuthSession | null;
role: LoginRole;
authMethod: AuthMethod | null;
registration: RegistrationState;
signInStatus: AsyncStatus;
signInError: ApiError | null;
/**
* Three wrong MPINs lock the profile and there is no in-app unlock, so this
* is a dead end the UI has to state plainly rather than a retryable error.
*/
mpinLocked: boolean;
lastProfile: LastProfile | null;
/**
* The verify-otp setup token, kept for the sign-in that follows it.
*
* Lives outside `registration` on purpose. The OTP screen calls
* `resetRegistration()` on its way to sign-in — registration really is over
* on this path, there is no MPIN to create — and the token has to outlive
* that reset to reach the login call. Dropped the moment a session opens.
*/
rebindToken: string | null;
/** A stored MPIN behind the device prompt, so biometric sign-in can work. */
biometricReady: boolean;
/**
* Whether `lastProfile` has actually been read off the Keystore yet. Without
* this, a null profile is ambiguous — not-yet-loaded looks identical to
* never-registered, and the two send the customer to different screens.
*/
signInContextLoaded: boolean;
}
/** Minimal shape thunks need from the store, avoiding a cycle through it. */
export interface AuthThunkState {
auth: AuthState;
}
+25
View File
@@ -1,5 +1,30 @@
import { MPIN_LENGTH } from '../../../utils/validation';
export {
MPIN_LENGTH,
isRepeatedDigits,
isValidMpin,
isValidOtp,
isValidPhone,
} from '../../../utils/validation';
/** Inline copy for why an MPIN was rejected before it ever left the device. */
export const describeMpinProblem = (value: string): string | undefined => {
if (value.length === 0) {
return undefined;
}
if (!/^[0-9]*$/.test(value)) {
return 'Digits only';
}
if (value.length !== MPIN_LENGTH) {
return `An MPIN is ${MPIN_LENGTH} digits`;
}
if (/^(\d)\1*$/.test(value)) {
return 'Pick an MPIN that is not the same digit repeated';
}
return undefined;
};
@@ -2,7 +2,7 @@ import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { AwarenessArticle } from '../types';
interface AwarenessCardProps {
@@ -102,7 +102,7 @@ const styles = StyleSheet.create({
},
category: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
@@ -113,11 +113,11 @@ const styles = StyleSheet.create({
},
urgentText: {
fontSize: 9,
fontWeight: '900',
fontFamily: fontFamily.bold,
},
title: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
bookmarkBtn: {
padding: 4,
@@ -143,7 +143,7 @@ const styles = StyleSheet.create({
},
tagText: {
fontSize: 10,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
readMore: {
flexDirection: 'row',
@@ -152,6 +152,6 @@ const styles = StyleSheet.create({
},
readMoreText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
@@ -5,7 +5,9 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { Card, ScreenShell } from '../../../components';
import { ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate } from '../../../features';
import { useTheme, fontFamily } from '../../../theme';
import { useAwareness } from '../hooks/useAwareness';
type Props = NativeStackScreenProps<ServicesStackParamList, 'AwarenessDetail'>;
@@ -105,6 +107,9 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
</View>
</View>
{/* The report button's only destination is the service-requests hub, so
it goes when the hub does rather than becoming a dead tap. */}
<FeatureGate feature={FeatureKey.SERVICES_REQUESTS_HUB}>
<View style={styles.footerActions}>
<Pressable
onPress={() => navigation.navigate('ServiceRequestsHub')}
@@ -114,6 +119,7 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
<Text style={[styles.reportText, { color: theme.colors.error }]}>Report Fraudulent Activity</Text>
</Pressable>
</View>
</FeatureGate>
</View>
<View style={{ height: 20 }} />
</ScreenShell>
@@ -149,17 +155,17 @@ const styles = StyleSheet.create({
},
categoryText: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
dateText: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
title: {
fontSize: 24,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
lineHeight: 30,
marginBottom: 12,
@@ -177,7 +183,7 @@ const styles = StyleSheet.create({
},
tagText: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
introCard: {
padding: 16,
@@ -186,7 +192,7 @@ const styles = StyleSheet.create({
description: {
fontSize: 15,
lineHeight: 22,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
contentSection: {
marginBottom: 32,
@@ -216,7 +222,7 @@ const styles = StyleSheet.create({
},
ctaTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
ctaSubtitle: {
fontSize: 13,
@@ -237,7 +243,7 @@ const styles = StyleSheet.create({
},
reportText: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
errorContainer: {
padding: 20,
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { Input, ScreenShell } from '../../../components';
import { ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { AwarenessCard } from '../components/AwarenessCard';
import { useAwareness } from '../hooks/useAwareness';
import { AwarenessCategory } from '../types';
@@ -27,7 +27,10 @@ export default function AwarenessListScreen({ navigation }: Props) {
};
return (
<ScreenShell title="Security aware" subtitle="Learn to protect your digital banking">
<ScreenShell
// The body is a FlatList, so it owns the scrolling.
scrollable={false}
title="Security aware" subtitle="Learn to protect your digital banking">
<View style={styles.searchContainer}>
<Input
placeholder="Search safety tips, fraud alerts..."
@@ -152,7 +155,7 @@ const styles = StyleSheet.create({
},
filterText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
listContent: {
paddingHorizontal: 16,
@@ -170,7 +173,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
urgentCarousel: {
@@ -192,7 +195,7 @@ const styles = StyleSheet.create({
},
urgentCardTitle: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
urgentCardDesc: {
fontSize: 12,
@@ -205,7 +208,7 @@ const styles = StyleSheet.create({
},
emptyTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
emptySubtitle: {
fontSize: 14,
@@ -6,7 +6,7 @@ import {
View,
} from 'react-native';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface CardControlToggleProps {
label: string;
@@ -65,7 +65,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 15,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
track: {
width: 52,
@@ -2,7 +2,7 @@ import React, { memo, useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { CardPhysicalDelivery } from '../../../types/app';
import { formatDate } from '../../../utils/formatters';
import {
@@ -185,13 +185,13 @@ const styles = StyleSheet.create({
},
eyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
title: {
fontSize: 17,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.2,
},
subtitle: {
@@ -208,7 +208,7 @@ const styles = StyleSheet.create({
},
badgeText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
metricsRow: {
flexDirection: 'row',
@@ -222,13 +222,13 @@ const styles = StyleSheet.create({
},
metricLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
metricValue: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
timelineRow: {
flexDirection: 'row',
@@ -257,7 +257,7 @@ const styles = StyleSheet.create({
},
timelineLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
lineHeight: 15,
},
footerCard: {
@@ -272,11 +272,11 @@ const styles = StyleSheet.create({
},
footerLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
footerValue: {
fontSize: 12,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
});
+13 -12
View File
@@ -18,6 +18,7 @@ import Animated, {
import type { BankCard } from '../../../types/app';
import { getCardStatusLabel } from '../utils/cardCopy';
import { fontFamily } from '../../../theme';
interface CardItemProps {
card: BankCard;
@@ -179,13 +180,13 @@ const styles = StyleSheet.create({
brandName: {
color: 'white',
fontSize: 18,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
cardMeta: {
color: 'rgba(255,255,255,0.72)',
fontSize: 10,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 1.1,
marginTop: 2,
},
@@ -206,7 +207,7 @@ const styles = StyleSheet.create({
statusBadgeText: {
color: 'white',
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
centerBlock: {
gap: 10,
@@ -214,20 +215,20 @@ const styles = StyleSheet.create({
cardName: {
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
},
cardNumber: {
color: 'white',
fontSize: 28,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: 2,
},
holderName: {
color: 'rgba(255,255,255,0.92)',
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
letterSpacing: 0.6,
},
footerRow: {
@@ -239,13 +240,13 @@ const styles = StyleSheet.create({
footerLabel: {
color: 'rgba(255,255,255,0.68)',
fontSize: 9,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 1,
},
footerValue: {
color: 'white',
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginTop: 4,
},
flipHint: {
@@ -256,7 +257,7 @@ const styles = StyleSheet.create({
flipText: {
color: 'rgba(255,255,255,0.9)',
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
magneticStrip: {
height: 42,
@@ -280,13 +281,13 @@ const styles = StyleSheet.create({
backLabel: {
color: 'rgba(255,255,255,0.68)',
fontSize: 10,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 0.9,
},
backValue: {
color: 'white',
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
backFooter: {
gap: 6,
@@ -294,7 +295,7 @@ const styles = StyleSheet.create({
backSupport: {
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -13,7 +13,7 @@ import Animated, {
} from 'react-native-reanimated';
import { Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface ExpandableSectionProps {
title: string;
@@ -120,7 +120,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
subtitle: {
@@ -134,7 +134,7 @@ const styles = StyleSheet.create({
},
badgeText: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
body: {
gap: 16,
+4 -4
View File
@@ -14,7 +14,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface LimitSliderProps {
label: string;
@@ -269,7 +269,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
helperText: {
fontSize: 12,
@@ -277,7 +277,7 @@ const styles = StyleSheet.create({
},
value: {
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
sliderRow: {
flexDirection: 'row',
@@ -324,7 +324,7 @@ const styles = StyleSheet.create({
},
scaleText: {
fontSize: 11,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -12,7 +12,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface ToggleSwitchProps {
label: string;
@@ -105,7 +105,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
description: {
fontSize: 12,
@@ -6,7 +6,7 @@ import {
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { CardTransaction } from '../../../types/app';
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
@@ -117,11 +117,11 @@ const styles = StyleSheet.create({
merchant: {
flex: 1,
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
amount: {
fontSize: 15,
fontWeight: '900',
fontFamily: fontFamily.bold,
textAlign: 'right',
},
rowBottom: {
@@ -132,7 +132,7 @@ const styles = StyleSheet.create({
},
meta: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
metaDot: {
width: 4,
@@ -147,7 +147,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 0.7,
},
});
@@ -11,9 +11,10 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { useAccounts } from '../../home/hooks/useAccounts';
import { useToast } from '../../../hooks/useToast';
import type { CardsStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
type Props = NativeStackScreenProps<CardsStackParamList, 'ApplyCreditCard'>;
@@ -58,14 +59,15 @@ type CreditProductId = (typeof CREDIT_PRODUCTS)[number]['id'];
export default function ApplyCreditCardScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const accounts = useAppSelector((state) => state.account.accounts);
// A card cannot be repaid from a deposit account.
const { operableAccounts: accounts } = useAccounts();
const profile = useAppSelector((state) => state.user.profile);
const [selectedProductId, setSelectedProductId] = useState<CreditProductId>(
CREDIT_PRODUCTS[0].id,
);
const [selectedRepaymentAccountId, setSelectedRepaymentAccountId] =
useState(accounts[0]?.id ?? '');
useState(accounts[0]?.accountNumber ?? '');
const [employmentType, setEmploymentType] =
useState<(typeof EMPLOYMENT_TYPES)[number]['id']>('salaried');
const [annualIncome, setAnnualIncome] = useState('');
@@ -80,7 +82,8 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
const selectedRepaymentAccount = useMemo(
() =>
accounts.find((account) => account.id === selectedRepaymentAccountId) ?? accounts[0],
accounts.find((account) => account.accountNumber === selectedRepaymentAccountId) ??
accounts[0],
[selectedRepaymentAccountId, accounts],
);
@@ -319,12 +322,12 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
<View style={styles.productList}>
{accounts.map((account) => {
const selected = account.id === selectedRepaymentAccountId;
const selected = account.accountNumber === selectedRepaymentAccountId;
return (
<Pressable
key={account.id}
onPress={() => setSelectedRepaymentAccountId(account.id)}
key={account.accountNumber}
onPress={() => setSelectedRepaymentAccountId(account.accountNumber)}
style={[
styles.accountCard,
{
@@ -336,10 +339,10 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
<View style={styles.productHeader}>
<View style={styles.productCopy}>
<Text style={[styles.productTitle, { color: theme.colors.textPrimary }]}>
{account.type} account
{account.accountType} account
</Text>
<Text style={[styles.productSubtitle, { color: theme.colors.textSecondary }]}>
{account.maskedNumber}
{'XXXX ' + account.accountNumber.slice(-4)}
</Text>
</View>
{selected ? (
@@ -347,7 +350,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
) : null}
</View>
<Text style={[styles.productMeta, { color: theme.colors.textPrimary }]}>
Available balance {formatCurrency(account.availableBalance)}
{account.ifsc}
</Text>
</Pressable>
);
@@ -431,7 +434,9 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
Repayment account
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{selectedRepaymentAccount?.maskedNumber ?? 'Not selected'}
{selectedRepaymentAccount
? 'XXXX ' + selectedRepaymentAccount.accountNumber.slice(-4)
: 'Not selected'}
</Text>
</View>
</Card>
@@ -473,7 +478,7 @@ const styles = StyleSheet.create({
},
heroTitle: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
heroSubtitle: {
@@ -492,13 +497,13 @@ const styles = StyleSheet.create({
},
heroStatLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroStatValue: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
formCard: {
gap: 14,
@@ -508,7 +513,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
sectionSubtitle: {
@@ -542,7 +547,7 @@ const styles = StyleSheet.create({
},
productTitle: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
productSubtitle: {
fontSize: 13,
@@ -553,7 +558,7 @@ const styles = StyleSheet.create({
},
productMeta: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
benefitList: {
gap: 8,
@@ -581,7 +586,7 @@ const styles = StyleSheet.create({
},
segmentText: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
consentRow: {
flexDirection: 'row',
@@ -616,12 +621,12 @@ const styles = StyleSheet.create({
},
summaryLabel: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
summaryValue: {
flex: 1,
textAlign: 'right',
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
});
+189 -140
View File
@@ -17,7 +17,7 @@ import { Button, Card, EmptyState, Input, ScreenShell } from '../../../component
import { useToast } from '../../../hooks/useToast';
import { useBiometricPrompt } from '../../../modules/auth/hooks/useBiometricPrompt';
import type { CardsStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type {
CardControlState,
CardLimitConfig,
@@ -34,6 +34,9 @@ import { TransactionItem } from '../components/TransactionItem';
import { useCardTransactionFilters } from '../hooks/useCardTransactionFilters';
import { selectCardById } from '../store/cardsSelectors';
import { cardsActions } from '../store/cardsSlice';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate, guardFeature, isFeatureEnabled } from '../../../features';
import { useAccounts } from "../../home/hooks/useAccounts";
import {
createVirtualCardPayload,
getCardDeliveryStatusLabel,
@@ -42,6 +45,18 @@ import {
type Props = NativeStackScreenProps<CardsStackParamList, 'CardDetails'>;
/**
* Feature that owns each card control switch. Kept beside the screen that uses
* it so the toggle UI and its guard cannot drift apart.
*/
const CARD_CONTROL_FEATURES: Partial<Record<keyof CardControlState, FeatureKey>> = {
frozen: FeatureKey.CARDS_CONTROLS_FREEZE,
online: FeatureKey.CARDS_CONTROLS_ONLINE,
international: FeatureKey.CARDS_CONTROLS_INTERNATIONAL,
contactless: FeatureKey.CARDS_CONTROLS_CONTACTLESS,
atm: FeatureKey.CARDS_CONTROLS_ATM,
};
const { width: screenWidth } = Dimensions.get('window');
const DETAIL_CARD_WIDTH = Math.min(screenWidth - 40, 360);
@@ -130,7 +145,8 @@ export default function CardDetailsScreen({ route }: Props) {
const toast = useToast();
const promptBiometric = useBiometricPrompt();
const card = useAppSelector((state) => selectCardById(state, route.params?.cardId));
const accounts = useAppSelector((state) => state.account.accounts);
// A card cannot be linked to a deposit account.
const { operableAccounts: accounts } = useAccounts();
const staticScroll = useSharedValue(0);
const [sensitiveVisible, setSensitiveVisible] = useState(false);
@@ -159,7 +175,7 @@ export default function CardDetailsScreen({ route }: Props) {
const linkedAccounts = useMemo(
() =>
card
? accounts.filter((account) => card.linkedAccountIds.includes(account.id))
? accounts.filter((account) => card.linkedAccountIds.includes(account.accountNumber))
: [],
[card, accounts],
);
@@ -176,6 +192,14 @@ export default function CardDetailsScreen({ route }: Props) {
}
const handleControlToggle = (key: keyof CardControlState) => {
// This handler is generic over the control, so the feature is resolved per
// key rather than with a single `guardFeature` wrapper.
const feature = CARD_CONTROL_FEATURES[key];
if (feature && !isFeatureEnabled(feature)) {
return;
}
const nextValue = !card.controls[key];
dispatch(
cardsActions.updateCardControls({
@@ -189,11 +213,16 @@ export default function CardDetailsScreen({ route }: Props) {
dispatch(cardsActions.toggleCardNotification({ cardId: card.id, key }));
};
const handleLimitChange = (key: keyof CardLimitConfig, value: number) => {
dispatch(cardsActions.updateCardLimits({ cardId: card.id, changes: { [key]: value } }));
};
// Guarded so a disabled control can never dispatch a change, whatever path
// reaches the handler.
const handleLimitChange = guardFeature(
FeatureKey.CARDS_CONTROLS_LIMITS,
(key: keyof CardLimitConfig, value: number) => {
dispatch(cardsActions.updateCardLimits({ cardId: card.id, changes: { [key]: value } }));
},
);
const handleToggleBlock = () => {
const handleToggleBlock = guardFeature(FeatureKey.CARDS_CONTROLS_BLOCK, () => {
const nextStatus = card.status === 'blocked' ? 'active' : 'blocked';
dispatch(cardsActions.setCardStatus({ cardId: card.id, status: nextStatus }));
toast(
@@ -203,9 +232,9 @@ export default function CardDetailsScreen({ route }: Props) {
? 'New transactions on this card are now stopped.'
: 'You can use this card again.',
);
};
});
const handleToggleFreeze = () => {
const handleToggleFreeze = guardFeature(FeatureKey.CARDS_CONTROLS_FREEZE, () => {
dispatch(
cardsActions.updateCardControls({
cardId: card.id,
@@ -220,7 +249,7 @@ export default function CardDetailsScreen({ route }: Props) {
? 'Your card is ready to use again.'
: 'Transactions on this card are paused for now.',
);
};
});
const handleRevealSensitive = async () => {
const authenticated = await promptBiometric();
@@ -385,99 +414,118 @@ export default function CardDetailsScreen({ route }: Props) {
badge={card.status === 'blocked' ? 'Blocked' : card.controls.frozen ? 'Frozen' : 'Active'}
>
<View style={styles.primaryActions}>
<Button
title={card.status === 'blocked' ? 'Unblock card' : 'Block card'}
variant={card.status === 'blocked' ? 'secondary' : 'danger'}
fullWidth={false}
style={styles.primaryActionButton}
onPress={handleToggleBlock}
leftAccessory={
<Ionicons
name={card.status === 'blocked' ? 'lock-open-outline' : 'lock-closed-outline'}
size={18}
color={card.status === 'blocked' ? theme.colors.textPrimary : theme.colors.white}
/>
}
/>
<Button
title={card.controls.frozen ? 'Unfreeze card' : 'Freeze card'}
variant="secondary"
fullWidth={false}
style={styles.primaryActionButton}
onPress={handleToggleFreeze}
leftAccessory={
<Ionicons
name={card.controls.frozen ? 'play-circle-outline' : 'snow-outline'}
size={18}
color={theme.colors.textPrimary}
/>
}
/>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_BLOCK}>
<Button
title={card.status === 'blocked' ? 'Unblock card' : 'Block card'}
variant={card.status === 'blocked' ? 'secondary' : 'danger'}
fullWidth={false}
style={styles.primaryActionButton}
onPress={handleToggleBlock}
leftAccessory={
<Ionicons
name={card.status === 'blocked' ? 'lock-open-outline' : 'lock-closed-outline'}
size={18}
color={card.status === 'blocked' ? theme.colors.textPrimary : theme.colors.white}
/>
}
/>
</FeatureGate>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_FREEZE}>
<Button
title={card.controls.frozen ? 'Unfreeze card' : 'Freeze card'}
variant="secondary"
fullWidth={false}
style={styles.primaryActionButton}
onPress={handleToggleFreeze}
leftAccessory={
<Ionicons
name={card.controls.frozen ? 'play-circle-outline' : 'snow-outline'}
size={18}
color={theme.colors.textPrimary}
/>
}
/>
</FeatureGate>
</View>
<ToggleSwitch
label="International transactions"
description="Allow overseas and foreign-currency transactions."
value={card.controls.international}
onValueChange={() => handleControlToggle('international')}
/>
<ToggleSwitch
label="Online payments"
description="Enable e-commerce and subscription spends."
value={card.controls.online}
onValueChange={() => handleControlToggle('online')}
/>
<ToggleSwitch
label="Contactless payments"
description="Enable tap-and-pay at supported merchants."
value={card.controls.contactless}
onValueChange={() => handleControlToggle('contactless')}
/>
<ToggleSwitch
label="ATM withdrawals"
description="Control cash withdrawals using this card."
value={card.controls.atm}
onValueChange={() => handleControlToggle('atm')}
/>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_INTERNATIONAL}>
<ToggleSwitch
label="International transactions"
description="Allow overseas and foreign-currency transactions."
value={card.controls.international}
onValueChange={() => handleControlToggle('international')}
/>
</FeatureGate>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_ONLINE}>
<ToggleSwitch
label="Online payments"
description="Enable e-commerce and subscription spends."
value={card.controls.online}
onValueChange={() => handleControlToggle('online')}
/>
</FeatureGate>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_CONTACTLESS}>
<ToggleSwitch
label="Contactless payments"
description="Enable tap-and-pay at supported merchants."
value={card.controls.contactless}
onValueChange={() => handleControlToggle('contactless')}
/>
</FeatureGate>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_ATM}>
<ToggleSwitch
label="ATM withdrawals"
description="Control cash withdrawals using this card."
value={card.controls.atm}
onValueChange={() => handleControlToggle('atm')}
/>
</FeatureGate>
<LimitSlider
label="Daily card limit"
helperText="Overall daily card usage across channels"
value={card.limits.daily}
min={10000}
max={300000}
step={5000}
onChange={(value) => handleLimitChange('daily', value)}
formatValue={formatCurrency}
onInteractionStart={() => setSliderInteractionActive(true)}
onInteractionEnd={() => setSliderInteractionActive(false)}
/>
<LimitSlider
label="ATM withdrawal limit"
helperText="Cash withdrawals at ATMs"
value={card.limits.atmWithdrawal}
min={5000}
max={100000}
step={5000}
onChange={(value) => handleLimitChange('atmWithdrawal', value)}
formatValue={formatCurrency}
onInteractionStart={() => setSliderInteractionActive(true)}
onInteractionEnd={() => setSliderInteractionActive(false)}
/>
<LimitSlider
label="POS purchase limit"
helperText="Swipe and chip purchase cap"
value={card.limits.pos}
min={10000}
max={200000}
step={5000}
onChange={(value) => handleLimitChange('pos', value)}
formatValue={formatCurrency}
onInteractionStart={() => setSliderInteractionActive(true)}
onInteractionEnd={() => setSliderInteractionActive(false)}
/>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_LIMITS}>
<LimitSlider
label="Daily card limit"
helperText="Overall daily card usage across channels"
value={card.limits.daily}
min={10000}
max={300000}
step={5000}
onChange={(value) => handleLimitChange('daily', value)}
formatValue={formatCurrency}
onInteractionStart={() => setSliderInteractionActive(true)}
onInteractionEnd={() => setSliderInteractionActive(false)}
/>
</FeatureGate>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_LIMITS}>
<LimitSlider
label="ATM withdrawal limit"
helperText="Cash withdrawals at ATMs"
value={card.limits.atmWithdrawal}
min={5000}
max={100000}
step={5000}
onChange={(value) => handleLimitChange('atmWithdrawal', value)}
formatValue={formatCurrency}
onInteractionStart={() => setSliderInteractionActive(true)}
onInteractionEnd={() => setSliderInteractionActive(false)}
/>
</FeatureGate>
<FeatureGate feature={FeatureKey.CARDS_CONTROLS_LIMITS}>
<LimitSlider
label="POS purchase limit"
helperText="Swipe and chip purchase cap"
value={card.limits.pos}
min={10000}
max={200000}
step={5000}
onChange={(value) => handleLimitChange('pos', value)}
formatValue={formatCurrency}
onInteractionStart={() => setSliderInteractionActive(true)}
onInteractionEnd={() => setSliderInteractionActive(false)}
/>
</FeatureGate>
</ExpandableSection>
<FeatureGate feature={FeatureKey.CARDS_TRANSACTIONS}>
<ExpandableSection
title="Transactions"
subtitle="Recent spends with filters by date, amount, and flow"
@@ -542,6 +590,7 @@ export default function CardDetailsScreen({ route }: Props) {
/>
)}
</ExpandableSection>
</FeatureGate>
<ExpandableSection
title="Card details"
@@ -582,25 +631,25 @@ export default function CardDetailsScreen({ route }: Props) {
<View style={styles.linkedAccounts}>
{linkedAccounts.map((account) => (
<View
key={account.id}
key={account.accountNumber}
style={[
styles.linkedAccountTile,
{
backgroundColor:
account.id === card.primaryLinkedAccountId
account.accountNumber === card.primaryLinkedAccountId
? `${theme.colors.primary}12`
: theme.colors.surfaceMuted,
},
]}
>
<Text style={[styles.linkedAccountLabel, { color: theme.colors.textSecondary }]}>
{account.type} account
{account.accountType} account
</Text>
<Text style={[styles.linkedAccountValue, { color: theme.colors.textPrimary }]}>
{account.maskedNumber}
{'XXXX ' + account.accountNumber.slice(-4)}
</Text>
<Text style={[styles.linkedAccountValue, { color: theme.colors.textSecondary }]}>
{formatCurrency(account.availableBalance)}
{account.ifsc}
</Text>
</View>
))}
@@ -955,12 +1004,12 @@ export default function CardDetailsScreen({ route }: Props) {
<View style={styles.linkedAccountSelector}>
{linkedAccounts.map((account) => (
<Pressable
key={`${account.id}_selector`}
key={`${account.accountNumber}_selector`}
onPress={() =>
dispatch(
cardsActions.setPrimaryLinkedAccount({
cardId: card.id,
accountId: account.id,
accountId: account.accountNumber,
}),
)
}
@@ -968,11 +1017,11 @@ export default function CardDetailsScreen({ route }: Props) {
styles.linkChip,
{
backgroundColor:
account.id === card.primaryLinkedAccountId
account.accountNumber === card.primaryLinkedAccountId
? `${theme.colors.primary}12`
: theme.colors.surfaceMuted,
borderColor:
account.id === card.primaryLinkedAccountId
account.accountNumber === card.primaryLinkedAccountId
? `${theme.colors.primary}35`
: theme.colors.cardBorder,
},
@@ -983,16 +1032,16 @@ export default function CardDetailsScreen({ route }: Props) {
styles.linkChipTitle,
{
color:
account.id === card.primaryLinkedAccountId
account.accountNumber === card.primaryLinkedAccountId
? theme.colors.primary
: theme.colors.textPrimary,
},
]}
>
{account.type}
{account.accountType}
</Text>
<Text style={[styles.linkChipValue, { color: theme.colors.textSecondary }]}>
{account.maskedNumber}
{'XXXX ' + account.accountNumber.slice(-4)}
</Text>
</Pressable>
))}
@@ -1028,13 +1077,13 @@ const styles = StyleSheet.create({
},
heroMetricLabel: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroMetricValue: {
fontSize: 21,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.4,
},
primaryActions: {
@@ -1050,7 +1099,7 @@ const styles = StyleSheet.create({
},
filterLabel: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
@@ -1067,7 +1116,7 @@ const styles = StyleSheet.create({
},
filterChipText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
secureGrid: {
flexDirection: 'row',
@@ -1081,13 +1130,13 @@ const styles = StyleSheet.create({
},
secureLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
secureValue: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
lineHeight: 22,
},
linkedAccounts: {
@@ -1100,13 +1149,13 @@ const styles = StyleSheet.create({
},
linkedAccountLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
linkedAccountValue: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
billingCard: {
flexDirection: 'row',
@@ -1118,14 +1167,14 @@ const styles = StyleSheet.create({
},
billingLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
billingValue: {
marginTop: 6,
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
maxWidth: 150,
},
virtualCardTile: {
@@ -1136,18 +1185,18 @@ const styles = StyleSheet.create({
},
virtualLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
virtualValue: {
fontSize: 18,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: 1,
},
virtualSubtext: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
budgetHeader: {
flexDirection: 'row',
@@ -1156,7 +1205,7 @@ const styles = StyleSheet.create({
},
budgetTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
budgetTrack: {
height: 12,
@@ -1169,7 +1218,7 @@ const styles = StyleSheet.create({
},
budgetCaption: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
chartRow: {
flexDirection: 'row',
@@ -1188,7 +1237,7 @@ const styles = StyleSheet.create({
},
chartLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
categoryList: {
gap: 10,
@@ -1212,11 +1261,11 @@ const styles = StyleSheet.create({
},
categoryListLabel: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
categoryListAmount: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
statementCard: {
borderRadius: 18,
@@ -1234,7 +1283,7 @@ const styles = StyleSheet.create({
},
statementTitle: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
statementSubtitle: {
fontSize: 12,
@@ -1242,7 +1291,7 @@ const styles = StyleSheet.create({
},
statementAmount: {
fontSize: 15,
fontWeight: '900',
fontFamily: fontFamily.bold,
},
statementAction: {
flexDirection: 'row',
@@ -1258,7 +1307,7 @@ const styles = StyleSheet.create({
},
statementActionText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
securityNote: {
flexDirection: 'row',
@@ -1280,20 +1329,20 @@ const styles = StyleSheet.create({
},
rewardEyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
rewardPoints: {
fontSize: 30,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.8,
marginTop: 4,
},
rewardValue: {
marginTop: 4,
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
offerStack: {
gap: 12,
@@ -1305,13 +1354,13 @@ const styles = StyleSheet.create({
},
offerMerchant: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
offerTitle: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
offerSubtitle: {
fontSize: 13,
@@ -1319,7 +1368,7 @@ const styles = StyleSheet.create({
},
offerCashback: {
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
defaultCardBanner: {
flexDirection: 'row',
@@ -1331,7 +1380,7 @@ const styles = StyleSheet.create({
},
defaultCardText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
linkedAccountSelector: {
flexDirection: 'row',
@@ -1347,10 +1396,10 @@ const styles = StyleSheet.create({
},
linkChipTitle: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
linkChipValue: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -10,10 +10,10 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { Button, Card, EmptyState, ScreenShell } from '../../../components';
import { selectAllAccounts } from '../../home/store/accountSelectors';
import { useAccounts } from '../../home/hooks/useAccounts';
import { useToast } from '../../../hooks/useToast';
import type { CardsStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { CardCarousel } from '../components/CardCarousel';
@@ -27,6 +27,8 @@ import {
selectSelectedCardIndex,
} from '../store/cardsSelectors';
import { cardsActions } from '../store/cardsSlice';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate } from '../../../features';
type Props = NativeStackScreenProps<CardsStackParamList, 'CardsOverview'>;
@@ -110,12 +112,14 @@ const CardServiceHub = ({
</Text>
</View>
</View>
<Button
title="Request debit card"
variant="secondary"
onPress={onRequestDebitCard}
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.textPrimary} />}
/>
<FeatureGate feature={FeatureKey.CARDS_REQUEST_PHYSICAL}>
<Button
title="Request debit card"
variant="secondary"
onPress={onRequestDebitCard}
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.textPrimary} />}
/>
</FeatureGate>
</View>
<View
@@ -177,11 +181,13 @@ const CardServiceHub = ({
</Text>
</View>
</View>
<Button
title="Apply credit card"
onPress={onApplyCreditCard}
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
/>
<FeatureGate feature={FeatureKey.CARDS_APPLY_CREDIT}>
<Button
title="Apply credit card"
onPress={onApplyCreditCard}
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
/>
</FeatureGate>
</View>
</View>
</Card>
@@ -196,7 +202,8 @@ export default function CardsOverviewScreen({ navigation }: Props) {
const cardsStatus = useAppSelector(selectCardsStatus);
const selectedCard = useAppSelector(selectSelectedCard);
const selectedIndex = useAppSelector(selectSelectedCardIndex);
const accounts = useAppSelector(selectAllAccounts);
// A card cannot be linked to a deposit account.
const { operableAccounts: accounts } = useAccounts();
const selectedTransactions = useMemo(
() =>
@@ -220,7 +227,9 @@ export default function CardsOverviewScreen({ navigation }: Props) {
const primaryLinkedAccount = useMemo(
() =>
selectedCard
? accounts.find((account) => account.id === selectedCard.primaryLinkedAccountId) ?? null
? accounts.find(
(account) => account.accountNumber === selectedCard.primaryLinkedAccountId,
) ?? null
: null,
[selectedCard, accounts],
);
@@ -349,13 +358,15 @@ export default function CardsOverviewScreen({ navigation }: Props) {
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{selectedCard.type === 'Credit'
? formatCompactCurrency(selectedCard.availableLimit)
: primaryLinkedAccount?.maskedNumber ?? 'Not linked'}
: primaryLinkedAccount
? 'XXXX ' + primaryLinkedAccount.accountNumber.slice(-4)
: 'Not linked'}
</Text>
<Text style={[styles.metricSubtext, { color: theme.colors.textMuted }]}>
{selectedCard.type === 'Credit'
? `Network ${selectedCard.network}`
: primaryLinkedAccount
? `${primaryLinkedAccount.type} account`
? `${primaryLinkedAccount.accountType} account`
: `Network ${selectedCard.network}`}
</Text>
</Card>
@@ -583,13 +594,13 @@ const styles = StyleSheet.create({
},
heroEyebrow: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
},
heroTitle: {
fontSize: 24,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.7,
marginTop: 4,
},
@@ -603,7 +614,7 @@ const styles = StyleSheet.create({
},
heroBadgeText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
metricGrid: {
flexDirection: 'row',
@@ -622,13 +633,13 @@ const styles = StyleSheet.create({
},
metricLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.9,
},
metricValue: {
fontSize: 22,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
metricSubtext: {
@@ -646,7 +657,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
sectionSubtitle: {
@@ -687,13 +698,13 @@ const styles = StyleSheet.create({
},
serviceEyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
serviceTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
serviceDescription: {
fontSize: 13,
@@ -711,7 +722,7 @@ const styles = StyleSheet.create({
},
serviceTagText: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 0.4,
},
insightCard: {
@@ -731,7 +742,7 @@ const styles = StyleSheet.create({
},
progressText: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
categoryChart: {
flexDirection: 'row',
@@ -750,18 +761,18 @@ const styles = StyleSheet.create({
},
categoryLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
categoryAmount: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
transactionsCard: {
gap: 4,
},
linkText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
rewardsCard: {
gap: 16,
@@ -776,13 +787,13 @@ const styles = StyleSheet.create({
},
offerMerchant: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.9,
},
offerTitle: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
offerSubtitle: {
fontSize: 13,
@@ -790,7 +801,7 @@ const styles = StyleSheet.create({
},
offerCashback: {
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
fab: {
position: 'absolute',
@@ -811,6 +822,6 @@ const styles = StyleSheet.create({
fabLabel: {
color: 'white',
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
});
@@ -13,12 +13,13 @@ import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { CardsStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { CardNetwork } from '../../../types/app';
import { formatCurrency, formatDate } from '../../../utils/formatters';
import { selectCards } from '../store/cardsSelectors';
import { cardsActions } from '../store/cardsSlice';
import { createMockPhysicalCard } from '../utils/cardCopy';
import { useAccounts } from "../../home/hooks/useAccounts";
type Props = NativeStackScreenProps<CardsStackParamList, 'RequestPhysicalCard'>;
@@ -68,10 +69,13 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const cards = useAppSelector(selectCards);
const accounts = useAppSelector((state) => state.account.accounts);
// A card cannot be linked to a deposit account.
const { operableAccounts: accounts } = useAccounts();
const profile = useAppSelector((state) => state.user.profile);
const [selectedAccountId, setSelectedAccountId] = useState(accounts[0]?.id ?? '');
const [selectedAccountId, setSelectedAccountId] = useState(
accounts[0]?.accountNumber ?? '',
);
const [selectedNetwork, setSelectedNetwork] = useState<CardNetwork>('RuPay');
const [deliveryMode, setDeliveryMode] =
useState<(typeof DELIVERY_OPTIONS)[number]['id']>('home');
@@ -80,11 +84,13 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
const [landmark, setLandmark] = useState('');
const selectedAccount = useMemo(
() => accounts.find((account) => account.id === selectedAccountId) ?? accounts[0],
() => accounts.find((account) => account.accountNumber === selectedAccountId) ?? accounts[0],
[selectedAccountId, accounts],
);
const issuanceFee = selectedAccount?.type === 'Salary' ? 0 : 199;
// The CBS account types are SAVINGS and CURRENT only — there is no salary
// type to waive the fee for any more, so the fee is flat.
const issuanceFee: number = 199;
const deliveryEta = deliveryMode === 'home' ? '3-5 business days' : 'Ready in 2 business days';
const estimatedDeliveryDate = useMemo(
() =>
@@ -112,7 +118,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
const newCard = createMockPhysicalCard({
index: cards.length + 1,
accountId: selectedAccount.id,
accountId: selectedAccount.accountNumber,
cardholderName: nameOnCard.trim(),
type: 'Debit',
network: selectedNetwork,
@@ -121,7 +127,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
destinationLabel:
deliveryMode === 'home'
? [deliveryAddress.trim(), landmark.trim()].filter(Boolean).join(', ')
: selectedAccount.branch,
: selectedAccount.ifsc,
});
dispatch(cardsActions.addCard(newCard));
@@ -203,12 +209,12 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
<View style={styles.choiceList}>
{accounts.map((account) => {
const selected = account.id === selectedAccountId;
const selected = account.accountNumber === selectedAccountId;
return (
<Pressable
key={account.id}
onPress={() => setSelectedAccountId(account.id)}
key={account.accountNumber}
onPress={() => setSelectedAccountId(account.accountNumber)}
style={[
styles.choiceCard,
{
@@ -220,10 +226,10 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
<View style={styles.choiceHeader}>
<View>
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
{account.type} account
{account.accountType} account
</Text>
<Text style={[styles.choiceSubtitle, { color: theme.colors.textSecondary }]}>
{account.maskedNumber} · {account.branch}
{'XXXX ' + account.accountNumber.slice(-4)} · {account.ifsc}
</Text>
</View>
{selected ? (
@@ -235,7 +241,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
) : null}
</View>
<Text style={[styles.choiceValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(account.availableBalance)}
{account.status}
</Text>
</Pressable>
);
@@ -376,7 +382,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
Pickup branch
</Text>
<Text style={[styles.pickupSubtitle, { color: theme.colors.textSecondary }]}>
{selectedAccount?.branch ?? 'Home branch'} will hold your card after dispatch.
Your home branch ({selectedAccount?.ifsc ?? 'IFSC'}) will hold your card after dispatch.
</Text>
</View>
</View>
@@ -406,7 +412,9 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
Linked account
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{selectedAccount?.maskedNumber ?? 'Not selected'}
{selectedAccount
? 'XXXX ' + selectedAccount.accountNumber.slice(-4)
: 'Not selected'}
</Text>
</View>
<View style={styles.summaryRow}>
@@ -480,7 +488,7 @@ const styles = StyleSheet.create({
},
heroTitle: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
heroSubtitle: {
@@ -499,13 +507,13 @@ const styles = StyleSheet.create({
},
heroStatLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroStatValue: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
formCard: {
gap: 14,
@@ -515,7 +523,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
sectionSubtitle: {
@@ -539,7 +547,7 @@ const styles = StyleSheet.create({
},
choiceTitle: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
choiceSubtitle: {
fontSize: 13,
@@ -547,7 +555,7 @@ const styles = StyleSheet.create({
},
choiceValue: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
networkTitleRow: {
flexDirection: 'row',
@@ -567,7 +575,7 @@ const styles = StyleSheet.create({
},
pickupTitle: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
pickupSubtitle: {
fontSize: 13,
@@ -584,13 +592,13 @@ const styles = StyleSheet.create({
},
summaryLabel: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
summaryValue: {
flex: 1,
textAlign: 'right',
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
bottomInset: {
height: 60,
@@ -3,7 +3,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { DepositAccount } from '../../../types/app';
import { formatCurrency, formatDate } from '../../../utils/formatters';
import { getDaysUntilMaturity, isDepositNearMaturity } from '../utils/depositCalculators';
@@ -49,7 +49,7 @@ const DepositAccountCardComponent = ({
<View style={styles.amountRow}>
<View>
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>
{deposit.kind === 'fd' ? 'Deposit amount' : 'Monthly installment'}
{deposit.kind === 'fd' ? 'Balance' : 'Monthly installment'}
</Text>
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(
@@ -58,6 +58,13 @@ const DepositAccountCardComponent = ({
: deposit.monthlyInstallment ?? deposit.principalAmount,
)}
</Text>
{deposit.kind === 'rd' &&
deposit.installmentsPaid !== undefined &&
deposit.tenureMonths > 0 ? (
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>
{deposit.installmentsPaid} of {deposit.tenureMonths} paid
</Text>
) : null}
</View>
<View
style={[
@@ -91,7 +98,7 @@ const DepositAccountCardComponent = ({
</View>
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Maturity amount
Projected maturity
</Text>
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.maturityAmount)}
@@ -145,13 +152,13 @@ const styles = StyleSheet.create({
},
eyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
number: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
amountRow: {
flexDirection: 'row',
@@ -161,12 +168,12 @@ const styles = StyleSheet.create({
},
amountLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
amountValue: {
marginTop: 4,
fontSize: 22,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
ratePill: {
@@ -179,7 +186,7 @@ const styles = StyleSheet.create({
},
rateText: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
metricsGrid: {
flexDirection: 'row',
@@ -194,13 +201,13 @@ const styles = StyleSheet.create({
},
metricLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
metricValue: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
lineHeight: 19,
},
});
@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface DepositOptionChipProps {
label: string;
@@ -50,7 +50,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
@@ -5,7 +5,7 @@ import {
View,
} from 'react-native';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface DepositRatePillProps {
rate: number;
@@ -39,7 +39,7 @@ const styles = StyleSheet.create({
},
text: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { DepositStatus } from '../../../types/app';
interface DepositStatusPillProps {
@@ -55,7 +55,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 10,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 0.7,
},
});
@@ -1,38 +1,104 @@
import { useMemo } from 'react';
import { usePullToRefresh } from '../../../hooks/usePullToRefresh';
import {
useGetFixedDepositsQuery,
useGetRecurringDepositsQuery,
} from '../services/depositApi';
import { useAccounts } from '../../home/hooks/useAccounts';
import { useGetAccountDepositsQuery } from '../services/depositApi';
/**
* Every deposit on the signed-in profile, with its terms.
*
* This takes two hops, because no single endpoint answers it. `GET /accounts`
* is the only source of full account numbers, and `GET
* /accounts/deposits/detail` is keyed by one — so the account list decides
* *which* deposits exist and the detail call fills in what each one is.
*
* That ordering is also why the query is skipped rather than called with an
* empty list: until the accounts land, "no deposit accounts" and "not asked
* yet" look identical, and firing on the latter would report an empty
* portfolio to someone who has deposits.
*/
const DEPOSIT_ACCOUNT_TYPES = ['FIXED_DEPOSIT', 'RECURRING_DEPOSIT'] as const;
export const useDepositPortfolio = () => {
const fixedDepositsQuery = useGetFixedDepositsQuery();
const recurringDepositsQuery = useGetRecurringDepositsQuery();
const {
accounts,
isLoading: accountsLoading,
isFetching: accountsFetching,
refetch: refetchAccounts,
error: accountsError,
} = useAccounts();
const deposits = useMemo(
const depositAccountNumbers = useMemo(
() =>
[...(fixedDepositsQuery.data ?? []), ...(recurringDepositsQuery.data ?? [])].sort(
(left, right) =>
new Date(right.startDate).getTime() - new Date(left.startDate).getTime(),
),
[fixedDepositsQuery.data, recurringDepositsQuery.data],
accounts
.filter((account) =>
DEPOSIT_ACCOUNT_TYPES.includes(
account.accountType as (typeof DEPOSIT_ACCOUNT_TYPES)[number],
),
)
.map((account) => account.accountNumber),
[accounts],
);
const hasLoadedAccounts = !accountsLoading && !accountsError;
const hasDepositAccounts = depositAccountNumbers.length > 0;
const depositsQuery = useGetAccountDepositsQuery(depositAccountNumbers, {
skip: !hasLoadedAccounts || !hasDepositAccounts,
});
const deposits = useMemo(() => {
const list = depositsQuery.data ?? [];
/**
* Newest first, by the date the deposit was opened. The endpoint may send
* no opened date at all, and an unparseable one sorts as `NaN` — which
* makes every comparison against it false and scatters those rows through
* the list. They go to the bottom instead, in a defined order.
*/
return [...list].sort((left, right) => {
const leftTime = new Date(left.startDate).getTime();
const rightTime = new Date(right.startDate).getTime();
const leftValid = Number.isFinite(leftTime);
const rightValid = Number.isFinite(rightTime);
if (!leftValid && !rightValid) {
return 0;
}
if (!leftValid) {
return 1;
}
if (!rightValid) {
return -1;
}
return rightTime - leftTime;
});
}, [depositsQuery.data]);
const { refreshing, onRefresh } = usePullToRefresh(async () => {
await Promise.all([
fixedDepositsQuery.refetch(),
recurringDepositsQuery.refetch(),
]);
// The account list is refetched first and awaited: it is what decides
// which deposits to ask about, so refreshing the details against a stale
// list would just re-fetch the old set.
await refetchAccounts();
if (hasDepositAccounts) {
await depositsQuery.refetch();
}
});
return {
deposits,
refreshing,
onRefresh,
isLoading: fixedDepositsQuery.isLoading || recurringDepositsQuery.isLoading,
isFetching: fixedDepositsQuery.isFetching || recurringDepositsQuery.isFetching,
isError: fixedDepositsQuery.isError || recurringDepositsQuery.isError,
error: fixedDepositsQuery.error ?? recurringDepositsQuery.error ?? null,
// A profile with no deposit accounts is a finished, empty answer — not a
// load still in flight, which is what a bare `isLoading` from a skipped
// query would otherwise imply.
isLoading: accountsLoading || (hasDepositAccounts && depositsQuery.isLoading),
isFetching: accountsFetching || depositsQuery.isFetching,
isError: Boolean(accountsError) || depositsQuery.isError,
error: accountsError ?? depositsQuery.error ?? null,
};
};
@@ -13,7 +13,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { useAppSelector } from '../../../app/hooks';
import { Card, Input, ScreenShell } from '../../../components';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
import {
@@ -228,7 +228,7 @@ const styles = StyleSheet.create({
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
@@ -258,7 +258,7 @@ const styles = StyleSheet.create({
},
resultTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
resultSubtitle: {
fontSize: 13,
@@ -266,7 +266,7 @@ const styles = StyleSheet.create({
},
resultValue: {
fontSize: 30,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.8,
},
resultGrid: {
@@ -282,13 +282,13 @@ const styles = StyleSheet.create({
},
resultLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
resultTileValue: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
lineHeight: 20,
},
});
@@ -13,14 +13,16 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { AppModal, Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate } from '../../../features';
import { useTheme, fontFamily } from '../../../theme';
import { formatCurrency, formatDate } from '../../../utils/formatters';
import { TransactionItem } from '../../cards/components/TransactionItem';
import { ExpandableSection } from '../../cards/components/ExpandableSection';
import { DepositStatusPill } from '../components/DepositStatusPill';
import {
useCloseDepositMutation,
useGetDepositDetailQuery,
useGetAccountDepositDetailQuery,
} from '../services/depositApi';
import {
calculatePrematureClosureAmount,
@@ -28,6 +30,7 @@ import {
getDepositProgress,
isDepositNearMaturity,
} from '../utils/depositCalculators';
import { toDepositAccount } from '../utils/depositDetailAdapter';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'DepositDetails'>,
@@ -38,10 +41,24 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
const theme = useTheme();
const toast = useToast();
const [confirmVisible, setConfirmVisible] = useState(false);
const { data: deposit, isLoading, isError, refetch } = useGetDepositDetailQuery({
depositId: route.params.depositId,
kind: route.params.kind,
});
/**
* `depositId` carries the deposit's *account number*: the overview lists
* deposits from `GET /accounts`, and `GET /accounts/deposits/detail` is
* keyed by the same number. `kind` still arrives in the route params — the
* overview knows it before this screen has fetched anything — but it plays
* no part in the lookup, because the payload states the deposit type itself.
*/
const {
data: detail,
isLoading,
isError,
refetch,
} = useGetAccountDepositDetailQuery(route.params.depositId);
const deposit = useMemo(
() => (detail ? toDepositAccount(detail) : undefined),
[detail],
);
const [closeDeposit, closeState] = useCloseDepositMutation();
const nearMaturity = deposit ? isDepositNearMaturity(deposit) : false;
@@ -168,7 +185,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
</View>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Interest earned
Projected interest
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.interestEarned)}
@@ -246,14 +263,16 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
{deposit.tenureMonths} months
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Payout frequency
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.payoutLabel}
</Text>
</View>
{deposit.productName || deposit.payoutLabel ? (
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
{deposit.productName ? 'Product' : 'Payout frequency'}
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.productName || deposit.payoutLabel}
</Text>
</View>
) : null}
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Settlement account
@@ -284,7 +303,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
<View style={styles.detailGrid}>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Total investment
Balance
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.totalInvestment)}
@@ -347,14 +366,18 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
Estimate new returns or close the deposit if you need funds earlier
</Text>
<Button
title="Open calculator"
variant="secondary"
onPress={() =>
navigation.navigate('DepositCalculator', { initialKind: deposit.kind })
}
leftAccessory={<Ionicons name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
/>
{/* Gated on the route it opens — the navigator will not register
DepositCalculator when the feature is off. */}
<FeatureGate feature={FeatureKey.SERVICES_DEPOSITS_CALCULATOR}>
<Button
title="Open calculator"
variant="secondary"
onPress={() =>
navigation.navigate('DepositCalculator', { initialKind: deposit.kind })
}
leftAccessory={<Ionicons name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
/>
</FeatureGate>
{canClose ? (
<Button
@@ -452,13 +475,13 @@ const styles = StyleSheet.create({
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
fontSize: 24,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.6,
},
heroMetrics: {
@@ -474,13 +497,13 @@ const styles = StyleSheet.create({
},
heroMetricLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroMetricValue: {
fontSize: 16,
fontWeight: '900',
fontFamily: fontFamily.bold,
lineHeight: 22,
},
progressWrap: {
@@ -497,7 +520,7 @@ const styles = StyleSheet.create({
},
progressCaption: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
detailGrid: {
flexDirection: 'row',
@@ -515,13 +538,13 @@ const styles = StyleSheet.create({
},
detailLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
detailValue: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
lineHeight: 20,
},
actionsCard: {
@@ -529,7 +552,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
sectionSubtitle: {
fontSize: 13,
@@ -546,11 +569,11 @@ const styles = StyleSheet.create({
},
modalLabel: {
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
modalValue: {
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
textAlign: 'right',
flex: 1,
},
@@ -12,8 +12,10 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
import { DepositAccountCard } from '../components/DepositAccountCard';
import { DepositOptionChip } from '../components/DepositOptionChip';
@@ -81,6 +83,16 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
const selectedFilterLabel =
FILTERS.find((item) => item.id === filter)?.label ?? 'All deposits';
/**
* Each action is gated on the route it opens. Without this the buttons
* render whatever the config says, while the navigator refuses to register
* the destination — a control that looks live and does nothing.
*/
const canOpenFd = useFeature(FeatureKey.SERVICES_DEPOSITS_OPEN_FD);
const canOpenRd = useFeature(FeatureKey.SERVICES_DEPOSITS_OPEN_RD);
const canCalculate = useFeature(FeatureKey.SERVICES_DEPOSITS_CALCULATOR);
const showQuickActions = canOpenFd || canOpenRd || canCalculate;
const handleOpenFixedDeposit = () => navigation.navigate('OpenFixedDeposit');
const handleOpenRecurringDeposit = () => navigation.navigate('OpenRecurringDeposit');
const handleOpenCalculator = () => navigation.navigate('DepositCalculator');
@@ -220,6 +232,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
</Card>
</View>
{showQuickActions ? (
<Card style={styles.actionCard}>
<View style={styles.sectionHeader}>
<View>
@@ -234,6 +247,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
<View style={styles.actionGrid}>
<View style={styles.actionRow}>
{canOpenFd ? (
<View style={styles.actionColumn}>
<Button
title="Open FD"
@@ -248,6 +262,8 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
}
/>
</View>
) : null}
{canOpenRd ? (
<View style={styles.actionColumn}>
<Button
title="Open RD"
@@ -263,7 +279,9 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
}
/>
</View>
) : null}
</View>
{canCalculate ? (
<Button
title="Calculator"
variant="secondary"
@@ -277,8 +295,10 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
/>
}
/>
) : null}
</View>
</Card>
) : null}
{summary.nearMaturityDeposits.length ? (
<Card style={styles.watchCard}>
@@ -384,9 +404,13 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
{!isLoading && !isError && !filteredDeposits.length ? (
<EmptyState
title="No deposits found"
description="Open a new fixed or recurring deposit to start building your savings ladder."
actionLabel="Open fixed deposit"
onAction={handleOpenFixedDeposit}
description={
canOpenFd
? 'Open a new fixed or recurring deposit to start building your savings ladder.'
: 'You have no fixed or recurring deposits with us. Visit your branch to open one.'
}
actionLabel={canOpenFd ? 'Open fixed deposit' : undefined}
onAction={canOpenFd ? handleOpenFixedDeposit : undefined}
/>
) : null}
@@ -437,13 +461,13 @@ const styles = StyleSheet.create({
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
fontSize: 20,
fontWeight: '900',
fontFamily: fontFamily.bold,
lineHeight: 26,
},
heroSubtitle: {
@@ -464,13 +488,13 @@ const styles = StyleSheet.create({
},
heroHighlightLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroHighlightValue: {
fontSize: 28,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.8,
},
heroHighlightCaption: {
@@ -488,12 +512,12 @@ const styles = StyleSheet.create({
},
heroBadgeValue: {
fontSize: 22,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
heroBadgeLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textAlign: 'center',
textTransform: 'uppercase',
letterSpacing: 0.5,
@@ -512,13 +536,13 @@ const styles = StyleSheet.create({
},
heroMetricLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroMetricValue: {
fontSize: 20,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.4,
},
heroMetricCaption: {
@@ -533,7 +557,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
sectionSubtitle: {
fontSize: 13,
@@ -572,7 +596,7 @@ const styles = StyleSheet.create({
},
watchTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
watchSubtitle: {
fontSize: 13,
@@ -592,15 +616,15 @@ const styles = StyleSheet.create({
},
watchDeposit: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
watchMeta: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
watchDays: {
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
borderRadius: 999,
overflow: 'hidden',
paddingHorizontal: 10,
@@ -630,12 +654,12 @@ const styles = StyleSheet.create({
},
portfolioCountValue: {
fontSize: 20,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.4,
},
portfolioCountLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
@@ -14,7 +14,7 @@ import { useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { DepositInterestPayoutType } from '../../../types/app';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
@@ -293,14 +293,14 @@ const styles = StyleSheet.create({
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
marginTop: 4,
fontSize: 24,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
heroPill: {
@@ -314,7 +314,7 @@ const styles = StyleSheet.create({
},
heroPillText: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
heroCaption: {
fontSize: 13,
@@ -328,7 +328,7 @@ const styles = StyleSheet.create({
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
@@ -339,14 +339,14 @@ const styles = StyleSheet.create({
},
errorText: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
previewCard: {
gap: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
previewGrid: {
flexDirection: 'row',
@@ -364,17 +364,17 @@ const styles = StyleSheet.create({
},
previewLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
previewValue: {
fontSize: 16,
fontWeight: '900',
fontFamily: fontFamily.bold,
lineHeight: 22,
},
placeholderText: {
fontSize: 14,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
});
@@ -14,7 +14,7 @@ import { useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
import { useCreateRecurringDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
@@ -256,14 +256,14 @@ const styles = StyleSheet.create({
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
marginTop: 4,
fontSize: 24,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
heroPill: {
@@ -277,7 +277,7 @@ const styles = StyleSheet.create({
},
heroPillText: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
heroCaption: {
fontSize: 13,
@@ -291,7 +291,7 @@ const styles = StyleSheet.create({
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
@@ -305,7 +305,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
previewGrid: {
flexDirection: 'row',
@@ -323,17 +323,17 @@ const styles = StyleSheet.create({
},
previewLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
previewValue: {
fontSize: 16,
fontWeight: '900',
fontFamily: fontFamily.bold,
lineHeight: 22,
},
placeholderText: {
fontSize: 14,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
});
+63 -1
View File
@@ -3,13 +3,14 @@ import { appConfig } from '../../../config/appConfig';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { endpoints } from '../../../services/api/endpoints';
import { toApiError } from '../../../services/api/serviceHelpers';
import { resolveLive, toApiError } from '../../../services/api/serviceHelpers';
import type {
DepositAccount,
DepositInterestPayoutType,
DepositKind,
DepositProduct,
} from '../../../types/app';
import type { CbsDepositDetail } from '../../../types/cbs';
import { sleep } from '../../../utils/async';
import {
calculateFixedDepositMaturity,
@@ -17,6 +18,7 @@ import {
calculateRecurringDepositMaturity,
getDepositCanonicalStatus,
} from '../utils/depositCalculators';
import { toDepositAccount } from '../utils/depositDetailAdapter';
interface DepositDetailArgs {
depositId: string;
@@ -316,6 +318,64 @@ export const depositApi = baseApi.injectEndpoints({
},
providesTags: ['Deposits'],
}),
/**
* One deposit's terms, straight from the bank.
*
* Live only — no mock branch. This endpoint is implemented on the CBS side,
* and `resolveWithMock` exists for the modules that have no backend yet;
* routing a real endpoint through fixtures would hide an outage behind
* demo numbers. A failure here is meant to reach the screen.
*
* Keyed by the full account number from `GET /accounts`. A masked number
* will not resolve, so this can only be asked about an account the
* profile already listed.
*/
getAccountDepositDetail: builder.query<CbsDepositDetail, string>({
queryFn: (accountNumber) =>
resolveLive(async () => {
const response = await apiClient.get<CbsDepositDetail>(
endpoints.accounts.depositDetail,
{ params: { accountNumber } },
);
return response.data;
}),
providesTags: ['Deposits'],
keepUnusedDataFor: 0,
}),
/**
* The same call, fanned out across every deposit on the profile, mapped
* into the shape the deposit screens render.
*
* The portfolio needs all of them at once and RTK Query cannot call a
* single-argument query a variable number of times from one component, so
* the fan-out happens here instead of in the screen.
*
* `Promise.all` rather than `allSettled`: a portfolio total computed from
* some of someone's deposits is worse than no total, because nothing on
* screen would say it was short. One failure fails the set.
*/
getAccountDeposits: builder.query<DepositAccount[], string[]>({
queryFn: (accountNumbers) =>
resolveLive(async () => {
const details = await Promise.all(
accountNumbers.map(async (accountNumber) => {
const response = await apiClient.get<CbsDepositDetail>(
endpoints.accounts.depositDetail,
{ params: { accountNumber } },
);
return response.data;
}),
);
return details.map(toDepositAccount);
}),
providesTags: ['Deposits'],
keepUnusedDataFor: 0,
}),
createFixedDeposit: builder.mutation<DepositAccount, CreateFixedDepositRequest>({
queryFn: async (payload) =>
appConfig.features.useMockServer
@@ -362,6 +422,8 @@ export const depositApi = baseApi.injectEndpoints({
export const {
useCloseDepositMutation,
useGetAccountDepositDetailQuery,
useGetAccountDepositsQuery,
useCreateFixedDepositMutation,
useCreateRecurringDepositMutation,
useGetDepositDetailQuery,
@@ -0,0 +1,118 @@
import type { CbsDepositDetail } from '../../../types/cbs';
import type { DepositAccount, DepositStatus } from '../../../types/app';
import { maskAccountNumber } from '../../home/utils/accountRules';
import {
calculateFixedDepositMaturity,
calculateRecurringDepositMaturity,
} from './depositCalculators';
/**
* `GET /accounts/deposits/detail` describes a deposit in the bank's terms;
* `DepositAccount` is what the deposit screens were built to render. This is
* the seam between them.
*
* The two shapes do not line up cleanly, and the gaps matter:
*
* - The endpoint returns no maturity *value*, only a maturity date. What the
* portfolio shows as "projected maturity" is therefore computed here with
* the app's own calculators, and only when the bank supplied the inputs to
* compute it with. When it did not — a recurring deposit with no rate or
* tenure, which the API explicitly permits — the projection falls back to
* the balance rather than inventing a return.
*
* - Nothing in this payload describes settlement, payout or premature
* closure. Those fields exist on `DepositAccount` and are filled with
* deliberately inert values below, never with plausible-looking guesses:
* a deposit sourced from here must not offer an action the bank has not
* said is available.
*/
/** Bank status strings, narrowed to the three the deposit screens know. */
const toDepositStatus = (status: string): DepositStatus => {
switch (status.toUpperCase()) {
case 'MATURED':
return 'matured';
case 'CLOSED':
return 'closed';
default:
// An account the bank returned at all is one it still holds. Anything
// it labels with a status this app has no case for is treated as live,
// which is the reading that keeps it visible rather than silently
// dropping it from the portfolio.
return 'active';
}
};
/**
* The projected value at maturity, and the interest that implies.
*
* Returns the balance unchanged when the bank did not send enough to project
* from — the honest answer for a deposit whose terms it did not disclose.
*/
const projectMaturity = (detail: CbsDepositDetail) => {
const { balance, interestRate, tenureMonths, installmentAmount } = detail;
const projected =
detail.depositType === 'FIXED_DEPOSIT'
? calculateFixedDepositMaturity(balance, interestRate ?? 0, tenureMonths ?? 0)
: calculateRecurringDepositMaturity(
installmentAmount ?? 0,
interestRate ?? 0,
tenureMonths ?? 0,
);
// Every calculator returns 0 when an input is missing or non-positive, which
// is the signal that there was nothing to project from.
const maturityAmount = projected > 0 ? projected : balance;
return {
maturityAmount,
/**
* The gap between the projection and today's balance — interest still to
* come, not interest already credited. The endpoint reports no earned
* figure, so the screens label this as projected rather than earned.
*/
interestEarned: Math.max(maturityAmount - balance, 0),
};
};
export const toDepositAccount = (detail: CbsDepositDetail): DepositAccount => {
const isFixed = detail.depositType === 'FIXED_DEPOSIT';
const { maturityAmount, interestEarned } = projectMaturity(detail);
return {
// The account number is the only stable identifier the endpoint carries,
// and it is what the endpoint is keyed by, so it doubles as the id.
id: detail.accountNumber,
productId: '',
kind: isFixed ? 'fd' : 'rd',
type: isFixed ? 'Fixed Deposit' : 'Recurring Deposit',
maskedNumber: maskAccountNumber(detail.accountNumber),
principalAmount: detail.balance,
monthlyInstallment: detail.installmentAmount ?? undefined,
totalInvestment: detail.balance,
interestRate: detail.interestRate ?? 0,
tenureMonths: detail.tenureMonths ?? 0,
startDate: detail.openedDate ?? '',
maturityDate: detail.maturityDate ?? '',
maturityAmount,
interestEarned,
productName: detail.productName,
installmentsPaid: detail.installmentsPaid ?? undefined,
status: toDepositStatus(detail.status),
/**
* Empty, not the product name. This field is the *payout frequency* —
* "Quarterly payout", "At maturity" — and the endpoint says nothing about
* one. Putting `productName` here rendered "Payout frequency: RD Product"
* on the details screen.
*/
payoutLabel: '',
// Not described by this endpoint. Left empty and false rather than
// guessed — see the note at the top of this file.
settlementAccountMasked: '',
canWithdrawPrematurely: false,
transactions: [],
};
};
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ScreenShell, Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppDispatch } from '../../../app/hooks';
import { logoutSession } from '../../auth/store/authThunk';
import { Button } from '../../../components/Button';
@@ -48,7 +48,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 24,
fontWeight: 'bold',
fontFamily: fontFamily.bold,
},
subtitle: {
fontSize: 16,
@@ -1,6 +1,6 @@
import React from 'react';
import { StyleSheet, Text, View, Pressable } from 'react-native';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface HomeSectionHeaderProps {
title: string;
@@ -32,11 +32,11 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
seeAll: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
@@ -1,12 +1,20 @@
import React from 'react';
import { StyleSheet, Text, View, ScrollView, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { FeatureKey } from '../../../constants/featureKeys';
import { useEnabledItems } from '../../../features';
import { useTheme, fontFamily } from '../../../theme';
interface Category {
id: string;
name: string;
icon: string;
/**
* The module this chip opens. Every one of these is a whole tab or stack, so
* an untagged chip is a chip that can navigate to a route the navigator no
* longer registers — which is a crash, not a no-op.
*/
feature: FeatureKey;
}
interface HorizontalCategoriesProps {
@@ -14,14 +22,21 @@ interface HorizontalCategoriesProps {
}
const CATEGORIES: Category[] = [
{ id: 'cards', name: 'Cards', icon: 'card-outline' },
{ id: 'deposits', name: 'Deposits', icon: 'wallet-outline' },
{ id: 'loans', name: 'Loans', icon: 'business-outline' },
{ id: 'insurance', name: 'Insurance', icon: 'shield-outline' },
{ id: 'cards', name: 'Cards', icon: 'card-outline', feature: FeatureKey.CARDS },
{ id: 'deposits', name: 'Deposits', icon: 'wallet-outline', feature: FeatureKey.SERVICES_DEPOSITS_OVERVIEW },
{ id: 'loans', name: 'Loans', icon: 'business-outline', feature: FeatureKey.LOANS },
{ id: 'insurance', name: 'Insurance', icon: 'shield-outline', feature: FeatureKey.INSURANCE },
];
export const HorizontalCategories = ({ onCategoryPress }: HorizontalCategoriesProps) => {
const theme = useTheme();
const categories = useEnabledItems(CATEGORIES);
// With every module behind these chips switched off, the row is an empty
// band of padding rather than a row of nothing.
if (categories.length === 0) {
return null;
}
return (
<ScrollView
@@ -29,7 +44,7 @@ export const HorizontalCategories = ({ onCategoryPress }: HorizontalCategoriesPr
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container}
>
{CATEGORIES.map((cat) => {
{categories.map((cat) => {
return (
<Pressable
key={cat.id}
@@ -108,6 +123,6 @@ const styles = StyleSheet.create({
},
name: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
+3 -3
View File
@@ -6,7 +6,7 @@ import {
} from 'react-native';
import type { InsightMetric, SpendingBreakdown } from '../../../types/app';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { formatCompactCurrency } from '../../../utils/formatters';
import { Card } from '../../../components';
@@ -60,7 +60,7 @@ const InsightsCardComponent = ({ insights, spending }: InsightsCardProps) => {
const styles = StyleSheet.create({
heading: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
metricRow: {
flexDirection: 'row',
@@ -77,7 +77,7 @@ const styles = StyleSheet.create({
},
metricValue: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
subheading: {
fontSize: 12,
+6 -6
View File
@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { StyleSheet, Text, View, Pressable, ImageBackground } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface PromoBannerProps {
title: string;
@@ -81,13 +81,13 @@ const styles = StyleSheet.create({
badgeText: {
color: 'white',
fontSize: 10,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 1,
},
rateText: {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
content: {
gap: 6,
@@ -96,13 +96,13 @@ const styles = StyleSheet.create({
title: {
color: 'white',
fontSize: 24,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
subtitle: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
fontWeight: '500',
fontFamily: fontFamily.medium,
lineHeight: 20,
maxWidth: '80%',
},
@@ -116,7 +116,7 @@ const styles = StyleSheet.create({
ctaText: {
color: 'white',
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
arrowCircle: {
width: 24,
+45 -13
View File
@@ -7,16 +7,38 @@ import {
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily, type CategoryHue } from '../../../theme';
import { Card } from '../../../components';
import { FeatureKey } from '../../../constants/featureKeys';
import { useEnabledItems } from '../../../features';
const CBS_ACTIONS = [
{ id: 'transfer', title: 'Transfer', subtitle: 'Send Funds', icon: 'send', color: '#1E40AF', route: 'Payments' },
{ id: 'payments', title: 'Pay Bills', subtitle: 'Utilities', icon: 'receipt', color: '#10B981', route: 'Payments' },
{ id: 'airtime', title: 'Airtime', subtitle: 'Recharge', icon: 'phone-portrait', color: '#8B5CF6', route: 'Payments' },
{ id: 'loans', title: 'Loans', subtitle: 'Manage', icon: 'cash', color: '#F59E0B', route: 'LoanFlow' },
{ id: 'cards', title: 'Cards', subtitle: 'Security', icon: 'card', color: '#EC4899', route: 'Cards' },
{ id: 'services', title: 'Stats', subtitle: 'Summary', icon: 'stats-chart', color: '#0EA5E9', route: 'Home' },
/**
* The tiles.
*
* Each names a categorical HUE rather than a hex. The hue is decorative — it
* exists so that neighbouring tiles are easy to tell apart, and it carries no
* state — so it resolves through `theme.colors.category`, which supplies a
* legible tone plus a matching tint chip for whichever theme is live.
*
* The previous `${hex}15` suffix built that chip by compositing the raw hue at
* 8% alpha over the card. That reads on white and disappears completely on a
* dark card, which is the specific bug the paired ramp exists to fix.
*/
const CBS_ACTIONS: ReadonlyArray<{
id: string;
feature: FeatureKey;
title: string;
subtitle: string;
icon: string;
hue: CategoryHue;
route: string;
}> = [
{ id: 'transfer', feature: FeatureKey.HOME_QUICK_ACTIONS_TRANSFER, title: 'Transfer', subtitle: 'Send Funds', icon: 'send', hue: 'blue', route: 'Payments' },
{ id: 'payments', feature: FeatureKey.HOME_QUICK_ACTIONS_BILL_PAY, title: 'Pay Bills', subtitle: 'Utilities', icon: 'receipt', hue: 'green', route: 'Payments' },
{ id: 'airtime', feature: FeatureKey.HOME_QUICK_ACTIONS_AIRTIME, title: 'Airtime', subtitle: 'Recharge', icon: 'phone-portrait', hue: 'violet', route: 'Payments' },
{ id: 'loans', feature: FeatureKey.HOME_QUICK_ACTIONS_LOANS, title: 'Loans', subtitle: 'Manage', icon: 'cash', hue: 'amber', route: 'LoanFlow' },
{ id: 'cards', feature: FeatureKey.HOME_QUICK_ACTIONS_CARDS, title: 'Cards', subtitle: 'Security', icon: 'card', hue: 'pink', route: 'Cards' },
{ id: 'services', feature: FeatureKey.HOME_QUICK_ACTIONS_STATS, title: 'Stats', subtitle: 'Summary', icon: 'stats-chart', hue: 'sky', route: 'Home' },
];
interface QuickActionsProps {
@@ -25,10 +47,11 @@ interface QuickActionsProps {
const QuickActionsComponent = ({ onPressRoute }: QuickActionsProps) => {
const theme = useTheme();
const actions = useEnabledItems(CBS_ACTIONS);
return (
<View style={styles.grid}>
{CBS_ACTIONS.map((action) => (
{actions.map((action) => (
<Card
key={action.id}
style={styles.card}
@@ -37,8 +60,17 @@ const QuickActionsComponent = ({ onPressRoute }: QuickActionsProps) => {
onPress={() => onPressRoute(action.route)}
style={styles.pressable}
>
<View style={[styles.iconContainer, { backgroundColor: `${action.color}15` }]}>
<Ionicons name={action.icon as any} size={22} color={action.color} />
<View
style={[
styles.iconContainer,
{ backgroundColor: theme.colors.category[action.hue].surface },
]}
>
<Ionicons
name={action.icon as any}
size={22}
color={theme.colors.category[action.hue].tone}
/>
</View>
<View>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{action.title}</Text>
@@ -79,14 +111,14 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
textAlign: 'center',
letterSpacing: -0.2,
},
subtitle: {
fontSize: 9,
marginTop: 2,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textAlign: 'center',
opacity: 0.6,
textTransform: 'uppercase',
@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface ServiceGridItemProps {
id: string;
@@ -78,7 +78,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
letterSpacing: -0.2,
lineHeight: 17,
},
+5 -5
View File
@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { Card } from '../../../components';
interface SummaryCardProps {
@@ -75,12 +75,12 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
date: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginTop: 1,
},
grid: {
@@ -94,13 +94,13 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
},
value: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
row: {
flexDirection: 'row',
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useMemo, useState } from 'react';
import { useGetAccountsQuery } from '../services/accountsApi';
import { isOperativeAccount } from '../utils/accountRules';
import type { CbsAccount } from '../../../types/cbs';
/**
* Re-exported so screens have one import for everything account-shaped. The
* rules themselves live in `accountRules.ts`, free of transport imports so
* they can be tested without standing up the API layer.
*/
export {
accountTypeLabel,
isOperativeAccount,
maskAccountNumber,
OPERATIVE_ACCOUNT_TYPES,
} from '../utils/accountRules';
/**
* Every account on the profile, read from the bank each time it is needed.
*
* `refetchOnMountOrArgChange` pairs with the endpoint's `keepUnusedDataFor: 0`
* to give the guarantee the screens rely on: nothing is served from a previous
* screen's copy. Account data is not held in Redux, not persisted, and not
* carried between screens in route params — it is fetched where it is shown.
*/
export const useAccounts = () => {
const { data, isLoading, isFetching, refetch, error } = useGetAccountsQuery(undefined, {
refetchOnMountOrArgChange: true,
});
const accounts = useMemo(() => data ?? [], [data]);
/**
* The accounts a picker may offer: active, and of a type that can transact.
*
* A dormant or frozen account still belongs to the customer and is worth
* listing on an enquiry screen, and so is a deposit — but neither can be the
* debit side of anything.
*/
const operableAccounts = useMemo(() => accounts.filter(isOperativeAccount), [accounts]);
return { accounts, operableAccounts, isLoading, isFetching, refetch, error };
};
/**
* `useAccounts` plus the "which one" state that every account-scoped form
* needs, with the selection latched onto the first account once the fetch
* lands — the first render legitimately has none.
*
* Defaults to the operable accounts, because the overwhelming majority of
* callers are pickers for an action. Pass `operableOnly: false` for the rare
* screen that is listing holdings rather than asking the customer to choose.
*/
export const useAccountSelection = (options?: { operableOnly?: boolean }) => {
const { accounts, operableAccounts, isLoading, isFetching, refetch, error } = useAccounts();
const list = options?.operableOnly === false ? accounts : operableAccounts;
const [selectedAccountNumber, setSelectedAccountNumber] = useState<string | undefined>();
useEffect(() => {
if (!list.length) {
return;
}
// Also covers the selected account disappearing between fetches — it was
// closed, or its status changed and it no longer qualifies.
if (
!selectedAccountNumber ||
!list.some((account) => account.accountNumber === selectedAccountNumber)
) {
setSelectedAccountNumber(list[0]?.accountNumber);
}
}, [list, selectedAccountNumber]);
const selectedAccount = list.find(
(account) => account.accountNumber === selectedAccountNumber,
);
return {
accounts: list,
selectedAccount,
selectedAccountNumber: selectedAccount?.accountNumber,
setSelectedAccountNumber,
isLoading,
isFetching,
refetch,
error,
};
};
export type { CbsAccount };
+150
View File
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useGetStatementQuery } from '../services/accountsApi';
import type { CbsStatementTransaction } from '../../../types/cbs';
/**
* Rows per request. Below the bank's cap of 100, and high enough that the
* common case — a customer glancing at a month — is one round trip.
*/
export const STATEMENT_PAGE_SIZE = 25;
/**
* A date in the `yyyy-MM-dd` the statement endpoint wants, read off the local
* calendar.
*
* Deliberately not `toISOString().slice(0, 10)`. That converts to UTC first,
* so for anyone east of Greenwich — every customer of this bank, at +05:30 —
* a date picked as the 17th is sent as the 16th for most of the day, and a
* range quietly loses its first day and gains a day at the other end.
*/
export const toStatementDate = (date: Date) => {
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
};
interface UseStatementArgs {
accountNumber?: string;
/** Sent only as a pair — see `CbsStatementQuery`. */
fromDate?: string;
toDate?: string;
}
/**
* An account's ledger, accumulated page by page.
*
* RTK Query holds one cache entry per page, which is what makes paging back
* free, but a list needs every page it has walked through. The pages are kept
* here keyed by the page number the *response* reports rather than by the one
* requested, so a bank that clamps or re-numbers a page cannot leave a row
* filed under the wrong index.
*
* The whole accumulation is thrown away when the account or the date range
* changes: those are different ledgers, and page 2 of the old one has no
* meaning in the new one.
*/
export const useStatement = ({ accountNumber, fromDate, toDate }: UseStatementArgs) => {
const queryKey = `${accountNumber ?? ''}|${fromDate ?? ''}|${toDate ?? ''}`;
const [page, setPage] = useState(0);
const [pages, setPages] = useState<Record<number, CbsStatementTransaction[]>>({});
const loadedKey = useRef(queryKey);
const { data, isLoading, isFetching, error, refetch } = useGetStatementQuery(
{
accountNumber: accountNumber ?? '',
fromDate,
toDate,
page,
size: STATEMENT_PAGE_SIZE,
},
{ skip: !accountNumber, refetchOnMountOrArgChange: true },
);
// Reset before the new key's first response lands, not after, so the list is
// never briefly the old account's rows under the new account's header.
if (loadedKey.current !== queryKey) {
loadedKey.current = queryKey;
if (page !== 0) {
setPage(0);
}
if (Object.keys(pages).length > 0) {
setPages({});
}
}
useEffect(() => {
if (!data) {
return;
}
setPages((current) =>
current[data.page] === data.content
? current
: { ...current, [data.page]: data.content },
);
}, [data]);
/**
* Flattened in page order, with repeats dropped.
*
* The endpoint warns that a page can repeat a row when its server-side cache
* expires mid-browse, and `referenceNumber` is the only stable key these
* rows carry — so the same entry arriving twice becomes one row here rather
* than a duplicate in the list and a duplicate in the totals.
*/
const transactions = useMemo(() => {
const seen = new Set<string>();
const ordered: CbsStatementTransaction[] = [];
Object.keys(pages)
.map(Number)
.sort((left, right) => left - right)
.forEach((index) => {
(pages[index] ?? []).forEach((item) => {
if (seen.has(item.referenceNumber)) {
return;
}
seen.add(item.referenceNumber);
ordered.push(item);
});
});
return ordered;
}, [pages]);
const hasMore = Boolean(data?.hasMore);
// True only while another page is on its way, never while the first one is.
const isLoadingMore = isFetching && page > 0;
const loadMore = useCallback(() => {
if (!hasMore || isFetching) {
return;
}
setPage((current) => current + 1);
}, [hasMore, isFetching]);
const reload = useCallback(() => {
setPages({});
setPage(0);
void refetch();
}, [refetch]);
return {
transactions,
totalElements: data?.totalElements ?? 0,
hasMore,
// The first page still being fetched, with nothing to show behind it.
isLoading: isLoading || (isFetching && transactions.length === 0),
isLoadingMore,
isFetching,
error,
loadMore,
reload,
};
};
@@ -1,14 +1,18 @@
import React, { useState, useRef } from 'react';
import { StyleSheet, Text, View, FlatList, Dimensions, Animated } from 'react-native';
import { StyleSheet, Text, View, Dimensions, Animated } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import LinearGradient from 'react-native-linear-gradient';
import { BalanceCard, Button, Card, ScreenShell } from '../../../components';
import { AccountCard, Button, Card, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerName } from '../../auth/store/authSelectors';
import { useAccounts } from '../hooks/useAccounts';
import type { HomeStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCurrency } from '../../../utils/formatters';
import { useTheme, fontFamily } from '../../../theme';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import { formatDate } from '../../../utils/formatters';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const SPACING = 16;
@@ -26,12 +30,18 @@ type DetailItem = {
export default function AccountDetailsScreen({ route, navigation }: Props) {
const theme = useTheme();
const accounts = useAppSelector((state) => state.account.accounts);
const canToggleBalance = useFeature(FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE);
// Accounts come from the bank on every mount and are never cached.
const { accounts } = useAccounts();
const customerName = useAppSelector(selectCustomerName);
const profile = useAppSelector((state) => state.user.profile);
const [selectedId, setSelectedId] = useState(route.params?.accountId);
const [selectedNumber, setSelectedNumber] = useState(route.params?.accountNumber);
const scrollX = useRef(new Animated.Value(0)).current;
const account = accounts.find((item) => item.id === selectedId) ?? accounts[0];
const activeIndex = accounts.findIndex((item) => item.id === selectedId);
const account =
accounts.find((item) => item.accountNumber === selectedNumber) ?? accounts[0];
const activeIndex = accounts.findIndex(
(item) => item.accountNumber === selectedNumber,
);
if (!account) {
return (
@@ -56,32 +66,43 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
);
}
const unclearedAmount = Math.max(account.currentBalance - account.availableBalance, 0);
const detailItems: DetailItem[] = [
{
id: 'holder',
// From the session, not from /accounts — that endpoint returns no name.
label: 'Account holder',
value: account.accountHolder,
value: customerName || '—',
icon: 'person-outline',
},
{
id: 'number',
label: 'Account number',
value: account.maskedNumber,
value: 'XXXX ' + account.accountNumber.slice(-4),
icon: 'card-outline',
},
{
id: 'type',
label: 'Account type',
value: account.type,
value: account.accountType,
icon: 'layers-outline',
},
{
id: 'branch',
label: 'Home branch',
value: account.branch,
icon: 'business-outline',
id: 'ifsc',
label: 'IFSC code',
value: account.ifsc || '—',
icon: 'barcode-outline',
},
{
id: 'mmid',
label: 'MMID',
value: account.mmid || 'Not allocated',
icon: 'swap-horizontal-outline',
},
{
id: 'status',
label: 'Account status',
value: account.status,
icon: 'pulse-outline',
},
];
@@ -110,6 +131,12 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
value: profile?.kycStatus === 'verified' ? 'Verified' : 'Pending',
icon: 'shield-checkmark-outline',
},
{
id: 'lastKyc',
label: 'Last KYC date',
value: profile?.lastKycDate ? formatDate(profile.lastKycDate) : 'Not available',
icon: 'calendar-outline',
},
...(profile?.ckycNumber ? [{
id: 'ckyc',
label: 'CKYC Number',
@@ -121,7 +148,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
return (
<ScreenShell
title="Account details"
subtitle={`${account.type} account overview`}
subtitle={`${account.accountType} account overview`}
contentContainerStyle={styles.contentContainer}
>
<View style={styles.carouselSection}>
@@ -145,7 +172,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
keyExtractor={(item) => item.id}
keyExtractor={(item) => item.accountNumber}
renderItem={({ item, index }) => {
const inputRange = [
(index - 1) * (CARD_WIDTH + SPACING),
@@ -160,7 +187,11 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
return (
<Animated.View style={{ width: CARD_WIDTH, marginRight: index !== accounts.length - 1 ? SPACING : 0, transform: [{ scale }] }}>
<BalanceCard account={item} secondaryLabel={`${item.type.toUpperCase()} ACCOUNT`} />
<AccountCard
account={item}
accountHolder={customerName || undefined}
canRevealNumber={canToggleBalance}
/>
</Animated.View>
);
}}
@@ -168,7 +199,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
const newIndex = Math.round(e.nativeEvent.contentOffset.x / (CARD_WIDTH + SPACING));
const safeIndex = Math.min(Math.max(newIndex, 0), accounts.length - 1);
if (accounts[safeIndex]) {
setSelectedId(accounts[safeIndex].id);
setSelectedNumber(accounts[safeIndex].accountNumber);
}
}}
/>
@@ -312,7 +343,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<Button
title="Re-KYC via Aadhaar"
onPress={() => navigation.navigate('ReKyc', { accountId: account.id })}
onPress={() => navigation.navigate('ReKyc', { accountNumber: account.accountNumber })}
leftAccessory={
<Ionicons name="finger-print-outline" size={18} color={theme.colors.white} />
}
@@ -383,10 +414,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Balance summary
Routing details
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Snapshot of your ledger and spendable funds
What someone else needs in order to pay you
</Text>
</View>
</View>
@@ -394,26 +425,18 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<View style={styles.balanceGrid}>
<View style={[styles.balanceTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.balanceTileLabel, { color: theme.colors.textSecondary }]}>
Available
IFSC
</Text>
<Text style={[styles.balanceTileValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(account.availableBalance)}
{account.ifsc || '—'}
</Text>
</View>
{/* <View style={[styles.balanceTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.balanceTileLabel, { color: theme.colors.textSecondary }]}>
Current
</Text>
<Text style={[styles.balanceTileValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(account.currentBalance)}
</Text>
</View> */}
<View style={[styles.balanceTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.balanceTileLabel, { color: theme.colors.textSecondary }]}>
Currency
MMID
</Text>
<Text style={[styles.balanceTileValue, { color: theme.colors.textPrimary }]}>
{account.currency}
{account.mmid || 'Not allocated'}
</Text>
</View>
</View>
@@ -422,7 +445,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<View style={styles.actionRow}>
<Button
title="Mini statement"
onPress={() => navigation.navigate('MiniStatement', { accountId: account.id })}
onPress={() => navigation.navigate('MiniStatement', { accountNumber: account.accountNumber })}
fullWidth={false}
style={styles.actionButton}
leftAccessory={
@@ -430,13 +453,13 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
}
/>
<Button
title="Back home"
title="E-Passbook"
variant="secondary"
onPress={() => navigation.goBack()}
onPress={() => navigation.navigate('EPassbook', { accountNumber: account.accountNumber })}
fullWidth={false}
style={styles.actionButton}
leftAccessory={
<Ionicons name="home-outline" size={18} color={theme.colors.textPrimary} />
<Ionicons name="book-outline" size={18} color={theme.colors.textPrimary} />
}
/>
</View>
@@ -480,13 +503,13 @@ const styles = StyleSheet.create({
},
highlightLabel: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
},
highlightTitle: {
fontSize: 22,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.6,
marginTop: 4,
},
@@ -505,7 +528,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
highlightStats: {
flexDirection: 'row',
@@ -520,11 +543,11 @@ const styles = StyleSheet.create({
},
highlightStatLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
highlightStatValue: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.4,
},
sectionCard: {
@@ -568,17 +591,17 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.4,
},
reKycTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
sectionSubtitle: {
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
reKycSubtitle: {
fontSize: 13,
@@ -604,13 +627,13 @@ const styles = StyleSheet.create({
},
detailLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.9,
},
detailValue: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
lineHeight: 20,
},
profileList: {
@@ -641,11 +664,11 @@ const styles = StyleSheet.create({
},
profileLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
profileValue: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
balanceGrid: {
flexDirection: 'row',
@@ -660,11 +683,11 @@ const styles = StyleSheet.create({
},
balanceTileLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
balanceTileValue: {
fontSize: 17,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.4,
},
actionRow: {
@@ -688,7 +711,7 @@ const styles = StyleSheet.create({
},
emptyTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
emptyDescription: {
fontSize: 14,
@@ -0,0 +1,257 @@
import React from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { useTheme, fontFamily } from '../../../theme';
import { describeError } from '../../../services/api/cbsErrors';
import type { ApiError } from '../../../types/api';
import type { CbsAccountStatus, MmidAccount } from '../../../types/cbs';
import { useGetMmidAccountsQuery } from '../services/accountsApi';
import type { HomeStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<HomeStackParamList, 'AccountsMmid'>>;
const STATUS_TONE: Record<CbsAccountStatus, 'success' | 'warning' | 'error' | 'muted'> = {
ACTIVE: 'success',
DORMANT: 'warning',
BLOCKED: 'error',
CLOSED: 'muted',
};
const titleCase = (value: string) =>
value.charAt(0) + value.slice(1).toLowerCase();
export default function AccountsMmidScreen(_props: Props) {
const theme = useTheme();
/**
* The bank's own masked view of the profile, rather than `GET /accounts`
* masked here.
*
* This is the one screen whose job is handing a number to someone else, and
* this endpoint is built for exactly that: it pairs each account's masked
* form and MMID with the registered mobile number a remitter needs beside
* it. Sourcing it here means no unmasked number is ever fetched for a screen
* that must not show one — the masking is the bank's, not a slice this
* screen could get wrong.
*
* Everything the app *acts* on still comes from `GET /accounts`. The two
* are not interchangeable and neither is a fallback for the other.
*/
const { data, isLoading, isFetching, refetch, error } = useGetMmidAccountsQuery(undefined, {
refetchOnMountOrArgChange: true,
});
const accounts = data?.accounts ?? [];
const mobileNumber = data?.mobileNumber;
const toneColor = (status: CbsAccountStatus) => {
switch (STATUS_TONE[status]) {
case 'success':
return theme.colors.success;
case 'warning':
return theme.colors.warning;
case 'error':
return theme.colors.error;
default:
return theme.colors.textMuted;
}
};
const renderAccount = (account: MmidAccount) => (
// Masked numbers repeat across accounts on this profile, so they cannot key
// the list. The MMID is unique per account where the bank has allocated
// one; the type disambiguates the deposits that have none.
<Card
key={account.mmid ?? `${account.accountType}-${account.accountNumberMasked}`}
style={styles.accountCard}
>
<View style={styles.accountHeader}>
<View style={styles.accountHeaderCopy}>
<Text style={[styles.accountType, { color: theme.colors.textPrimary }]}>
{titleCase(account.accountType)}
</Text>
<Text style={[styles.accountNumber, { color: theme.colors.textSecondary }]}>
{account.accountNumberMasked}
</Text>
</View>
<View
style={[styles.statusPill, { backgroundColor: `${toneColor(account.status)}1A` }]}
>
<Text style={[styles.statusText, { color: toneColor(account.status) }]}>
{titleCase(account.status)}
</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
{/* No MMID means no row. The bank allocates none against a deposit
account, so a "not allocated yet" line would be permanent on an FD or
RD — it reads as something pending that will never arrive. */}
{account.mmid ? (
<View style={styles.detailRow}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>MMID</Text>
<Text style={[styles.mmid, { color: theme.colors.textPrimary }]}>{account.mmid}</Text>
</View>
) : null}
<View style={styles.detailRow}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>IFSC</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{account.ifsc}
</Text>
</View>
</Card>
);
const apiError = error as ApiError | undefined;
return (
<ScreenShell
title="Accounts & MMID"
subtitle="Share these to receive money by IMPS"
scrollable={false}
contentContainerStyle={styles.container}
>
{isLoading ? (
<Loader />
) : apiError ? (
<EmptyState
title={describeError(apiError.code).title}
description={apiError.message || describeError(apiError.code).description}
actionLabel="Try again"
onAction={refetch}
/>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl
refreshing={isFetching}
onRefresh={refetch}
tintColor={theme.colors.primary}
/>
}
>
<View style={[styles.mobileCard, { backgroundColor: `${theme.colors.primary}0D` }]}>
<Ionicons name="call-outline" size={18} color={theme.colors.primary} />
<View style={styles.mobileCopy}>
<Text style={[styles.mobileLabel, { color: theme.colors.textSecondary }]}>
REGISTERED MOBILE
</Text>
<Text style={[styles.mobileValue, { color: theme.colors.textPrimary }]}>
{mobileNumber ?? '—'}
</Text>
</View>
</View>
<Text style={[styles.hint, { color: theme.colors.textSecondary }]}>
A remitter needs your mobile number and the MMID of the account together to send you
money by IMPS.
</Text>
{accounts.length ? (
accounts.map(renderAccount)
) : (
<EmptyState
title="No linked accounts"
description="Your bank has not linked any accounts to this profile."
/>
)}
</ScrollView>
)}
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 16,
},
scrollContent: {
gap: 12,
paddingBottom: 24,
},
mobileCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 14,
borderRadius: 12,
},
mobileCopy: {
gap: 2,
},
mobileLabel: {
fontSize: 10,
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
},
mobileValue: {
fontSize: 17,
fontFamily: fontFamily.bold,
letterSpacing: 1,
},
hint: {
fontSize: 13,
lineHeight: 18,
paddingHorizontal: 2,
},
accountCard: {
padding: 16,
gap: 12,
},
accountHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
accountHeaderCopy: {
flex: 1,
gap: 2,
},
accountType: {
fontSize: 16,
fontFamily: fontFamily.bold,
},
accountNumber: {
fontSize: 13,
letterSpacing: 1,
},
statusPill: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
},
statusText: {
fontSize: 11,
fontFamily: fontFamily.bold,
},
divider: {
height: 1,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
detailLabel: {
fontSize: 12,
fontFamily: fontFamily.semibold,
letterSpacing: 0.3,
},
detailValue: {
fontSize: 14,
fontFamily: fontFamily.bold,
},
mmid: {
fontSize: 18,
fontFamily: fontFamily.bold,
letterSpacing: 2,
},
});
@@ -0,0 +1,205 @@
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerName } from '../../auth/store/authSelectors';
import { useAccounts } from '../hooks/useAccounts';
import { AccountSelector } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { HomeStackParamList } from '../../../navigation/types';
import { useTheme, fontFamily } from '../../../theme';
import { formatCurrency, formatDate } from '../../../utils/formatters';
type Props = NativeStackScreenProps<HomeStackParamList, 'EPassbook'>;
type LedgerRow = {
id: string;
date: string;
particulars: string;
withdrawal: number;
deposit: number;
};
export default function EPassbookScreen({ route }: Props) {
const theme = useTheme();
const toast = useToast();
// Accounts come from the bank on every mount and are never cached.
// The chooser offers operable accounts only — a deposit has no passbook
// ledger of the kind this screen renders, and cannot be transacted on.
const { operableAccounts: accounts } = useAccounts();
const transactions = useAppSelector((state) => state.account.transactions);
const customerName = useAppSelector(selectCustomerName);
const profile = useAppSelector((state) => state.user.profile);
const [selectedNumber, setSelectedNumber] = useState(route.params?.accountNumber);
const account =
accounts.find((item) => item.accountNumber === selectedNumber) ?? accounts[0];
/**
* The entries, newest first.
*
* There is no running-balance column any more. It used to be derived by
* working backwards from the account's current balance, and no CBS endpoint
* returns a balance to work back from — a column of numbers derived from a
* figure the bank never sent would be fiction in the one place a customer is
* most likely to trust it.
*/
const ledger = useMemo<LedgerRow[]>(() => {
if (!account) return [];
return [...transactions]
.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())
.slice(0, 12)
.map((txn) => ({
id: txn.id,
date: formatDate(txn.occurredAt),
particulars: txn.title || txn.counterparty,
withdrawal: txn.direction === 'debit' ? txn.amount : 0,
deposit: txn.direction === 'credit' ? txn.amount : 0,
}));
}, [transactions, account]);
if (!account) {
return (
<ScreenShell title="E-Passbook" subtitle="Account ledger">
<Card style={styles.emptyCard}>
<Ionicons name="book-outline" size={28} color={theme.colors.textMuted} />
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No account found</Text>
</Card>
</ScreenShell>
);
}
return (
<ScreenShell title="E-Passbook" subtitle="Digital passbook & ledger" contentContainerStyle={styles.content}>
{/* Account selector */}
{accounts.length > 1 ? (
<AccountSelector
accounts={accounts}
selectedAccountNumber={account.accountNumber}
onSelect={setSelectedNumber}
/>
) : null}
{/* Passbook cover */}
<Card style={styles.coverCard}>
<View style={styles.coverHeader}>
<View style={[styles.coverIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="book-outline" size={20} color={theme.colors.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.coverName, { color: theme.colors.textPrimary }]}>{customerName || "Account holder"}</Text>
<Text style={[styles.coverMeta, { color: theme.colors.textSecondary }]}>
{profile?.customerId ? `Cust ID ${profile.customerId}` : 'Customer'}
</Text>
</View>
</View>
<View style={styles.coverGrid}>
<View style={styles.coverItem}>
<Text style={[styles.coverLabel, { color: theme.colors.textSecondary }]}>A/C Number</Text>
<Text style={[styles.coverValue, { color: theme.colors.textPrimary }]}>{`XXXX ${account.accountNumber.slice(-4)}`}</Text>
</View>
<View style={styles.coverItem}>
<Text style={[styles.coverLabel, { color: theme.colors.textSecondary }]}>IFSC</Text>
<Text style={[styles.coverValue, { color: theme.colors.textPrimary }]}>{account.ifsc || '—'}</Text>
</View>
<View style={styles.coverItem}>
<Text style={[styles.coverLabel, { color: theme.colors.textSecondary }]}>Type</Text>
<Text style={[styles.coverValue, { color: theme.colors.textPrimary }]}>{account.accountType}</Text>
</View>
<View style={styles.coverItem}>
<Text style={[styles.coverLabel, { color: theme.colors.textSecondary }]}>Status</Text>
<Text style={[styles.coverValue, { color: theme.colors.primary }]}>{account.status}</Text>
</View>
</View>
</Card>
{/* Ledger */}
<Card style={styles.ledgerCard}>
<View style={[styles.ledgerHead, { borderBottomColor: theme.colors.cardBorder }]}>
<Text style={[styles.thDate, { color: theme.colors.textSecondary }]}>Date</Text>
<Text style={[styles.thParticulars, { color: theme.colors.textSecondary }]}>Particulars</Text>
<Text style={[styles.thAmount, { color: theme.colors.textSecondary }]}>Amount</Text>
</View>
{ledger.map((row) => (
<View key={row.id} style={[styles.ledgerRow, { borderBottomColor: theme.colors.cardBorder }]}>
<Text style={[styles.tdDate, { color: theme.colors.textSecondary }]}>{row.date}</Text>
<View style={styles.tdParticulars}>
<Text style={[styles.particularsText, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{row.particulars}
</Text>
<Text
style={[
styles.particularsAmount,
{ color: row.deposit > 0 ? theme.colors.success : theme.colors.danger },
]}
>
{row.deposit > 0 ? `+ ${formatCurrency(row.deposit)}` : `- ${formatCurrency(row.withdrawal)}`}
</Text>
</View>
</View>
))}
{ledger.length === 0 && (
<Text style={[styles.noRows, { color: theme.colors.textMuted }]}>No entries to display.</Text>
)}
</Card>
<View style={styles.actionRow}>
<Button
title="Download PDF"
fullWidth={false}
style={styles.actionBtn}
onPress={() => toast('Download started', 'success', 'Your e-passbook is being saved to your device.')}
leftAccessory={<Ionicons name="cloud-download-outline" size={18} color={theme.colors.white} />}
/>
<Button
title="Share"
variant="secondary"
fullWidth={false}
style={styles.actionBtn}
onPress={() => toast('Share sheet', 'info', 'Choose an app to share your e-passbook.')}
leftAccessory={<Ionicons name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
/>
</View>
<View style={{ height: 40 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
content: { gap: 16 },
selector: { gap: 10, paddingRight: 12 },
accountChip: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1.5, gap: 2, minWidth: 120 },
chipType: { fontSize: 12, fontFamily: fontFamily.bold, textTransform: 'uppercase' },
chipMask: { fontSize: 12, fontFamily: fontFamily.medium },
coverCard: { padding: 18, gap: 16 },
coverHeader: { flexDirection: 'row', alignItems: 'center', gap: 12 },
coverIcon: { width: 44, height: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
coverName: { fontSize: 17, fontFamily: fontFamily.bold },
coverMeta: { fontSize: 12, fontFamily: fontFamily.medium },
coverGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
coverItem: { width: '47%', gap: 4 },
coverLabel: { fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', letterSpacing: 0.6 },
coverValue: { fontSize: 14, fontFamily: fontFamily.bold },
ledgerCard: { padding: 16 },
ledgerHead: { flexDirection: 'row', paddingBottom: 10, borderBottomWidth: 1 },
thDate: { width: 64, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase' },
thParticulars: { flex: 1, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', paddingHorizontal: 8 },
thAmount: { width: 84, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', textAlign: 'right' },
ledgerRow: { flexDirection: 'row', paddingVertical: 12, borderBottomWidth: 1, alignItems: 'center' },
tdDate: { width: 64, fontSize: 11, fontFamily: fontFamily.semibold },
tdParticulars: { flex: 1, paddingHorizontal: 8, gap: 2 },
particularsText: { fontSize: 13, fontFamily: fontFamily.bold },
particularsAmount: { fontSize: 12, fontFamily: fontFamily.semibold },
tdBalance: { width: 84, fontSize: 12, fontFamily: fontFamily.bold, textAlign: 'right' },
noRows: { fontSize: 13, textAlign: 'center', paddingVertical: 20 },
actionRow: { flexDirection: 'row', gap: 12 },
actionBtn: { flex: 1 },
emptyCard: { alignItems: 'center', paddingVertical: 32, gap: 12 },
emptyTitle: { fontSize: 16, fontFamily: fontFamily.bold },
});
+478 -210
View File
@@ -1,23 +1,58 @@
import React, { useState, useRef } from 'react';
import { StyleSheet, View, Text, ScrollView, Pressable, FlatList, Dimensions, Animated } from 'react-native';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Animated,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
useWindowDimensions,
View,
} from 'react-native';
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, BalanceCard } from '../../../components';
import { useTheme } from '../../../theme';
import { ScreenShell, AccountCard, EmptyState, Loader } from '../../../components';
import { useTheme, fontFamily } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
import { HomeSectionHeader } from '../components/HomeSectionHeader';
import { ServiceGridItem } from '../components/ServiceGridItem';
import { PromoBanner } from '../components/PromoBanner';
import { HorizontalCategories } from '../components/HorizontalCategories';
import { selectUnreadNotificationCount } from '../../notifications/store/notificationsSlice';
import { selectCustomerName } from '../../auth/store/authSelectors';
import {
accountTypeLabel,
maskAccountNumber,
OPERATIVE_ACCOUNT_TYPES,
useAccounts,
} from '../hooks/useAccounts';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate, useFeature } from '../../../features';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const SPACING = 16;
const CARD_WIDTH = SCREEN_WIDTH - 32;
/** The shell's gutter plus this screen's, which the carousel has to match. */
const GUTTER = 16;
/**
* Greeting by the clock rather than a fixed string.
*
* "Good afternoon" was hard-coded, so the app greeted everyone the same way at
* seven in the morning and at midnight. The boundaries are the ordinary ones,
* and deliberately not localised — this app ships in one locale.
*/
const greetingForHour = (hour: number) => {
if (hour < 12) {
return 'Good morning';
}
if (hour < 17) {
return 'Good afternoon';
}
return 'Good evening';
};
type Props = CompositeScreenProps<
NativeStackScreenProps<HomeStackParamList, 'HomeMain'>,
@@ -26,14 +61,78 @@ type Props = CompositeScreenProps<
export default function HomeScreen({ navigation }: Props) {
const theme = useTheme();
const accounts = useAppSelector((state) => state.account.accounts);
const { width: screenWidth } = useWindowDimensions();
const canRevealNumber = useFeature(FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE);
// Read from the bank on every mount. Accounts are not held in Redux.
const { accounts, isLoading: accountsLoading, isFetching, refetch } = useAccounts();
/**
* Measured from the live window rather than a module-scope `Dimensions.get`.
*
* The old constant was captured once, when the module first loaded, so a
* rotation or a split-screen resize left every card at the width of the
* previous layout — and the carousel snapped to an interval that no longer
* matched what was on screen.
*/
const cardWidth = screenWidth - GUTTER * 2;
const snapInterval = cardWidth + SPACING;
/**
* The carousel is the day-to-day view, so it carries savings and current
* accounts only. A fixed or recurring deposit would sit here beside them
* offering the same row of actions — mini statement, MMID, account details —
* and the bank refuses all of them on a deposit. Deposits get their own
* section below, which links to the screen that shows their terms.
*
* Filtered on type alone rather than through `isOperativeAccount`: that also
* requires ACTIVE, and a dormant savings account is still the customer's
* money and still belongs on their home screen, even though nothing can be
* debited from it.
*/
const carouselAccounts = useMemo(
() => accounts.filter((item) => OPERATIVE_ACCOUNT_TYPES.includes(item.accountType)),
[accounts],
);
/**
* The deposits this profile holds.
*
* `/accounts` returns these in the same payload and the screen was throwing
* them away — the customer's own fixed and recurring deposits were fetched,
* filtered out of the carousel, and then never mentioned anywhere. This is
* real data already in hand, which is the only kind this screen may show:
* the CBS API returns no balance and no transactions, so a figure or a
* recent-activity feed here would have to be invented.
*/
const depositAccounts = useMemo(
() => accounts.filter((item) => !OPERATIVE_ACCOUNT_TYPES.includes(item.accountType)),
[accounts],
);
const customerName = useAppSelector(selectCustomerName);
const profile = useAppSelector((state) => state.user.profile);
const unreadNotifications = useAppSelector(selectUnreadNotificationCount);
const [activeIndex, setActiveIndex] = useState(0);
const scrollX = useRef(new Animated.Value(0)).current;
const account = accounts[activeIndex] || accounts[0];
const account = carouselAccounts[activeIndex] ?? carouselAccounts[0];
const navigateToDeposits = React.useCallback(
/**
* The session's name first, the profile's only as a fallback.
*
* The header read `profile.firstName + profile.lastName` while the card
* beside it used `customerName`, so the two could — and did — disagree. A
* profile that had not loaded also rendered as a bare space rather than
* anything a person could read.
*/
const displayName = useMemo(() => {
const fromProfile = [profile?.firstName, profile?.lastName].filter(Boolean).join(' ').trim();
return customerName || fromProfile || 'there';
}, [customerName, profile?.firstName, profile?.lastName]);
const greeting = useMemo(() => greetingForHour(new Date().getHours()), []);
const navigateToDeposits = useCallback(
(initialKind?: 'fd' | 'rd') => {
navigation.navigate('Services', {
screen: 'DepositsOverview',
@@ -43,15 +142,15 @@ export default function HomeScreen({ navigation }: Props) {
[navigation],
);
const navigateToLoans = React.useCallback(() => {
const navigateToLoans = useCallback(() => {
navigation.navigate('LoanFlow');
}, [navigation]);
const navigateToInsurance = React.useCallback(() => {
const navigateToInsurance = useCallback(() => {
navigation.navigate('InsuranceFlow');
}, [navigation]);
const handleCategoryPress = React.useCallback(
const handleCategoryPress = useCallback(
(categoryId: string) => {
if (categoryId === 'cards') {
navigation.navigate('Cards');
@@ -75,169 +174,323 @@ export default function HomeScreen({ navigation }: Props) {
[navigateToDeposits, navigateToInsurance, navigateToLoans, navigation],
);
if (!account) {
return null; // or show an error state
}
/**
* One account action.
*
* A tile, not the pill this used to be. Those were three flexed pills with
* the icon and label side by side, and a third of the screen is not wide
* enough for "Mini Statement" at 13pt bold — the label overran its own pill
* and collided with the icon of the next one. Stacking the label under the
* icon gives it the full tile width and two lines to use, so the longest
* label on the screen still fits.
*/
const renderAction = (
icon: React.ComponentProps<typeof Ionicons>['name'],
label: string,
onPress: () => void,
) => (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={({ pressed }) => [
styles.actionTile,
theme.elevation.level1,
{
backgroundColor: pressed ? theme.colors.surfaceMuted : theme.colors.surface,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.lg,
},
]}
>
<View style={[styles.actionIcon, { backgroundColor: theme.colors.primarySurface }]}>
<Ionicons name={icon} size={20} color={theme.colors.primary} />
</View>
<Text numberOfLines={2} style={[styles.actionLabel, { color: theme.colors.textPrimary }]}>
{label}
</Text>
</Pressable>
);
return (
<ScreenShell scrollable={false}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
refreshControl={
// The empty state below tells the customer to pull down to try
// again. Until now there was nothing to pull — the instruction was
// simply untrue.
<RefreshControl
refreshing={isFetching && !accountsLoading}
onRefresh={refetch}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
>
{/* Top Header */}
{/* ── Who is signed in, and the two ways off this screen ──────────── */}
<View style={styles.header}>
<View style={styles.profileRow}>
{/* <View style={[styles.avatarFrame, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="person" size={24} color={theme.colors.primary} />
</View> */}
<View style={{ marginLeft: 8 }}>
<Text style={[styles.greeting, { color: theme.colors.textSecondary }]}>Good afternoon</Text>
<Text style={[styles.userName, { color: theme.colors.textPrimary }]}>
{profile?.firstName} {profile?.lastName}
</Text>
</View>
<Text style={[styles.greeting, { color: theme.colors.textSecondary }]}>
{greeting}
</Text>
<Text numberOfLines={1} style={[styles.userName, { color: theme.colors.textPrimary }]}>
{displayName}
</Text>
</View>
<View style={styles.headerActions}>
<Pressable
style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}
onPress={() => navigation.navigate('Notifications')}
>
<Ionicons name="notifications-outline" size={22} color={theme.colors.textPrimary} />
{unreadNotifications > 0 ? (
<View style={[styles.badge, { backgroundColor: theme.colors.danger, borderColor: theme.colors.surface }]}>
<Text style={styles.badgeText}>
{unreadNotifications > 9 ? '9+' : unreadNotifications}
</Text>
<FeatureGate feature={FeatureKey.NOTIFICATIONS_LIST}>
<Pressable
accessibilityRole="button"
accessibilityLabel={
unreadNotifications > 0
? `Notifications, ${unreadNotifications} unread`
: 'Notifications'
}
style={[
styles.iconButton,
theme.elevation.level1,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
},
]}
onPress={() => navigation.navigate('Notifications')}
>
<Ionicons
name="notifications-outline"
size={20}
color={theme.colors.textPrimary}
/>
{unreadNotifications > 0 ? (
<View
style={[
styles.badge,
{
backgroundColor: theme.colors.danger,
borderColor: theme.colors.surface,
},
]}
>
<Text style={[styles.badgeText, { color: theme.colors.textOnBrand }]}>
{unreadNotifications > 9 ? '9+' : unreadNotifications}
</Text>
</View>
) : null}
</Pressable>
</FeatureGate>
<FeatureGate feature={FeatureKey.SEARCH_GLOBAL}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Search"
style={[
styles.iconButton,
theme.elevation.level1,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
},
]}
onPress={() => navigation.navigate('GlobalSearch')}
>
<Ionicons name="search-outline" size={20} color={theme.colors.textPrimary} />
</Pressable>
</FeatureGate>
</View>
</View>
{/* ── The accounts themselves ─────────────────────────────────────── */}
<FeatureGate feature={FeatureKey.HOME_ACCOUNTS_CAROUSEL}>
{accountsLoading && carouselAccounts.length === 0 ? (
<View style={styles.carouselSection}>
<Loader />
</View>
) : carouselAccounts.length === 0 ? (
<View style={styles.carouselSection}>
{/*
Two different empty states, because they call for different
things from the customer. Nothing at all read back is a problem
worth retrying; a profile that holds only deposits read back
fine, and telling that customer to pull to refresh would send
them after an account they do not have.
*/}
{accounts.length === 0 ? (
<EmptyState
title="No accounts to show"
description="We could not read any accounts for this profile. Pull down to try again."
/>
) : (
<EmptyState
title="No everyday accounts"
description="This profile holds no savings or current account. Your deposits are listed below."
/>
)}
</View>
) : (
<View style={styles.carouselSection}>
<Animated.FlatList
data={carouselAccounts}
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={snapInterval}
snapToAlignment="start"
disableIntervalMomentum
decelerationRate="fast"
contentContainerStyle={styles.carouselContent}
keyExtractor={(item) => item.accountNumber}
onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], {
useNativeDriver: true,
})}
scrollEventThrottle={16}
renderItem={({ item, index }) => {
const inputRange = [
(index - 1) * snapInterval,
index * snapInterval,
(index + 1) * snapInterval,
];
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.95, 1, 0.95],
extrapolate: 'clamp',
});
return (
<Animated.View
style={{
width: cardWidth,
marginRight: index === carouselAccounts.length - 1 ? 0 : SPACING,
transform: [{ scale }],
}}
>
<AccountCard
account={item}
accountHolder={customerName || undefined}
canRevealNumber={canRevealNumber}
/>
</Animated.View>
);
}}
onMomentumScrollEnd={(event) => {
const next = Math.round(event.nativeEvent.contentOffset.x / snapInterval);
setActiveIndex(Math.min(Math.max(next, 0), carouselAccounts.length - 1));
}}
/>
{carouselAccounts.length > 1 ? (
<View style={styles.pagination}>
{carouselAccounts.map((item, index) => (
<View
key={item.accountNumber}
style={[
styles.paginationDot,
{
backgroundColor:
activeIndex === index
? theme.colors.primary
: theme.colors.cardBorderStrong,
width: activeIndex === index ? 22 : 7,
},
]}
/>
))}
</View>
) : null}
</Pressable>
<Pressable
style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}
onPress={() => navigation.navigate('GlobalSearch')}
>
<Ionicons name="search-outline" size={22} color={theme.colors.textPrimary} />
</Pressable>
</View>
</View>
{/* Main Card Section (Carousel) */}
<View style={styles.carouselSection}>
<Animated.FlatList
data={accounts}
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={CARD_WIDTH + SPACING}
snapToAlignment="start"
disableIntervalMomentum={true}
decelerationRate="fast"
contentContainerStyle={styles.carouselContent}
keyExtractor={(item) => item.id}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { x: scrollX } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
renderItem={({ item, index }) => {
const inputRange = [
(index - 1) * (CARD_WIDTH + SPACING),
index * (CARD_WIDTH + SPACING),
(index + 1) * (CARD_WIDTH + SPACING),
];
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.95, 1, 0.95],
extrapolate: 'clamp',
});
return (
<Animated.View style={{ width: CARD_WIDTH, marginRight: index !== accounts.length - 1 ? SPACING : 0, transform: [{ scale }] }}>
<BalanceCard account={item} />
</Animated.View>
);
}}
onMomentumScrollEnd={(e) => {
const newIndex = Math.round(e.nativeEvent.contentOffset.x / (CARD_WIDTH + SPACING));
setActiveIndex(Math.min(Math.max(newIndex, 0), accounts.length - 1));
}}
/>
{accounts.length > 1 && (
<View style={styles.pagination}>
{accounts.map((_, i) => (
<View
key={i}
style={[
styles.paginationDot,
{
backgroundColor:
activeIndex === i ? theme.colors.primary : theme.colors.textMuted,
width: activeIndex === i ? 24 : 8,
},
]}
/>
))}
</View>
)}
</View>
</FeatureGate>
{/* Action Buttons Row */}
<View style={styles.actionRow}>
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface }]} onPress={() => navigation.navigate('MiniStatement', { accountId: account.id })}>
<Ionicons name="download-outline" size={18} color={theme.colors.primary} />
<Text style={[styles.actionBtnText, { color: theme.colors.textPrimary }]}>Mini Statement</Text>
</Pressable>
<Pressable
style={[styles.actionBtn, { backgroundColor: theme.colors.surface }]}
onPress={() => navigation.navigate('AccountDetails', { accountId: account.id })}
>
<Ionicons name="list-outline" size={18} color={theme.colors.primary} />
<Text style={[styles.actionBtnText, { color: theme.colors.textPrimary }]}>Account details</Text>
</Pressable>
</View>
{/* ── What can be done with the account on screen ─────────────────── */}
{account ? (
<View style={styles.actionRow}>
<FeatureGate feature={FeatureKey.HOME_ACCOUNTS_MINI_STATEMENT}>
{renderAction('receipt-outline', 'Mini statement', () =>
navigation.navigate('MiniStatement', { accountNumber: account.accountNumber }),
)}
</FeatureGate>
{/* Horizontal Categories */}
<HorizontalCategories onCategoryPress={handleCategoryPress} />
<FeatureGate feature={FeatureKey.HOME_ACCOUNTS_DETAILS}>
{renderAction('information-circle-outline', 'Account details', () =>
navigation.navigate('AccountDetails', { accountNumber: account.accountNumber }),
)}
</FeatureGate>
{/* Quick Actions Grid */}
<View style={styles.section}>
{/* <HomeSectionHeader title="Quick actions" /> */}
{/* <View style={styles.grid}>
<ServiceGridItem id="transfer" title="One time Transfer" icon="send-outline" iconColor="#F59E0B" />
<ServiceGridItem id="self" title="Self Transfer" icon="swap-horizontal-outline" iconColor="#10B981" />
<ServiceGridItem id="bill" title="Bill Pay" icon="receipt-outline" iconColor="#6366F1" />
<ServiceGridItem id="collect" title="Collect Request" icon="download-outline" iconColor="#3B82F6" />
<ServiceGridItem id="payee" title="Manage Payee" icon="people-outline" iconColor="#EF4444" />
</View> */}
</View>
{/* Explore Services Grid */}
{/* <View style={styles.section}>
<HomeSectionHeader title="Explore services" />
<View style={styles.grid}>
<ServiceGridItem id="support" title="Support" icon="headset-outline" iconColor="#EF4444" />
<ServiceGridItem
id="insurance"
title="Insurance"
icon="shield-checkmark-outline"
iconColor="#10B981"
onPress={navigateToInsurance}
/>
<ServiceGridItem
id="fd"
title="FD"
icon="trending-up-outline"
iconColor="#F59E0B"
onPress={() => navigateToDeposits('fd')}
/>
<ServiceGridItem id="gold" title="Gold Loan" icon="diamond-outline" iconColor="#3B82F6" onPress={navigateToLoans} />
{/* A remitter needs the mobile number and MMID together to send
money by IMPS, so both live on one screen. */}
<FeatureGate feature={FeatureKey.HOME_ACCOUNTS_MMID}>
{renderAction('qr-code-outline', 'MMID', () => navigation.navigate('AccountsMmid'))}
</FeatureGate>
</View>
</View> */}
) : null}
{/* Loan Offers Section - Moved to Bottom */}
{/* ── Deposits this profile actually holds ────────────────────────── */}
{depositAccounts.length > 0 ? (
<FeatureGate feature={FeatureKey.SERVICES_DEPOSITS_OVERVIEW}>
<View style={styles.section}>
<HomeSectionHeader title="Your deposits" onSeeAll={() => navigateToDeposits()} />
<View style={styles.depositList}>
{depositAccounts.map((item) => (
<Pressable
key={item.accountNumber}
accessibilityRole="button"
accessibilityLabel={`${accountTypeLabel(item.accountType)} ending ${item.accountNumber.slice(-4)}`}
onPress={() =>
navigateToDeposits(item.accountType === 'RECURRING_DEPOSIT' ? 'rd' : 'fd')
}
style={({ pressed }) => [
styles.depositRow,
theme.elevation.level1,
{
backgroundColor: pressed ? theme.colors.surfaceMuted : theme.colors.surface,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.lg,
},
]}
>
<View
style={[styles.depositIcon, { backgroundColor: theme.colors.primarySurface }]}
>
<Ionicons
name={
item.accountType === 'RECURRING_DEPOSIT'
? 'repeat-outline'
: 'lock-closed-outline'
}
size={18}
color={theme.colors.primary}
/>
</View>
<View style={styles.footerSpacer} />
<View style={styles.depositCopy}>
<Text style={[styles.depositTitle, { color: theme.colors.textPrimary }]}>
{accountTypeLabel(item.accountType)}
</Text>
{/* Masked, as everywhere outside a confirmation step —
`/accounts` is the one endpoint that hands back a
full number. */}
<Text style={[styles.depositMeta, { color: theme.colors.textSecondary }]}>
{maskAccountNumber(item.accountNumber)}
{item.status === 'ACTIVE' ? '' : ` · ${item.status}`}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={theme.colors.textMuted} />
</Pressable>
))}
</View>
</View>
</FeatureGate>
) : null}
{/* ── Everything else the bank has switched on ────────────────────── */}
<View style={styles.section}>
<HomeSectionHeader title="Explore" />
<HorizontalCategories onCategoryPress={handleCategoryPress} />
</View>
</ScrollView>
</ScreenShell>
);
@@ -245,35 +498,30 @@ export default function HomeScreen({ navigation }: Props) {
const styles = StyleSheet.create({
scrollContent: {
paddingBottom: 20,
paddingBottom: 12,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 20,
},
profileRow: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 8,
paddingBottom: 20,
gap: 12,
},
avatarFrame: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
profileRow: {
flex: 1,
gap: 2,
},
greeting: {
fontSize: 12,
fontWeight: '500',
fontSize: 13,
fontFamily: fontFamily.medium,
},
userName: {
fontSize: 16,
fontWeight: '800',
letterSpacing: -0.2,
fontSize: 20,
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
headerActions: {
flexDirection: 'row',
@@ -283,18 +531,14 @@ const styles = StyleSheet.create({
width: 42,
height: 42,
borderRadius: 21,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 5,
elevation: 2,
},
badge: {
position: 'absolute',
top: 4,
right: 4,
top: 3,
right: 3,
minWidth: 18,
height: 18,
borderRadius: 9,
@@ -304,16 +548,12 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
badgeText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '800',
},
section: {
paddingHorizontal: 6,
marginBottom: 24,
fontFamily: fontFamily.bold,
},
carouselSection: {
marginBottom: 24,
marginBottom: 20,
},
carouselContent: {
paddingHorizontal: 6,
@@ -323,44 +563,72 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
gap: 6,
marginTop: 16,
marginTop: 14,
},
paginationDot: {
height: 8,
height: 7,
borderRadius: 4,
},
actionRow: {
flexDirection: 'row',
paddingHorizontal: 6,
gap: 12,
marginBottom: 24,
gap: 10,
marginBottom: 28,
},
actionBtn: {
actionTile: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 14,
paddingHorizontal: 8,
borderWidth: StyleSheet.hairlineWidth,
},
actionIcon: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
gap: 8,
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 4,
elevation: 1,
},
actionBtnText: {
fontSize: 13,
fontWeight: '700',
actionLabel: {
fontSize: 12,
lineHeight: 16,
textAlign: 'center',
fontFamily: fontFamily.semibold,
},
grid: {
section: {
paddingHorizontal: 6,
marginBottom: 28,
},
depositList: {
gap: 10,
},
depositRow: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
padding: 14,
borderWidth: StyleSheet.hairlineWidth,
},
footerSpacer: {
height: 40,
depositIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
depositCopy: {
flex: 1,
gap: 2,
},
depositTitle: {
fontSize: 14,
fontFamily: fontFamily.bold,
},
depositMeta: {
fontSize: 12,
fontFamily: fontFamily.medium,
},
});
File diff suppressed because it is too large Load Diff
+239 -17
View File
@@ -11,9 +11,9 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import LinearGradient from 'react-native-linear-gradient';
import { BottomSheet, Button, Card, Input, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { useAccounts } from '../hooks/useAccounts';
import type { HomeStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
type Props = NativeStackScreenProps<HomeStackParamList, 'ReKyc'>;
type IconName = React.ComponentProps<typeof Ionicons>['name'];
@@ -34,8 +34,22 @@ const CHANGE_OPTIONS: ChangeOption[] = [
const STEPS = [
'Enter your Aadhaar number',
'Select the fields that changed',
'Submit the re-KYC request for review',
'Upload documents & latest photograph',
'Accept the self-declaration and submit',
];
type DocOption = { id: string; label: string; hint: string; icon: IconName };
const DOCUMENT_OPTIONS: DocOption[] = [
{ id: 'identity', label: 'Identity proof', hint: 'Aadhaar / Passport / Voter ID', icon: 'id-card-outline' },
{ id: 'address', label: 'Address proof', hint: 'Utility bill / Rent agreement', icon: 'home-outline' },
{ id: 'pan', label: 'PAN card', hint: 'Required for tax records', icon: 'card-outline' },
];
const DECLARATIONS = [
'The information and documents provided are true and correct.',
'I will inform the bank of any future change in my KYC details.',
'I consent to the bank verifying my documents with the issuing authority.',
];
const formatAadhaar = (value: string) =>
@@ -48,9 +62,11 @@ const extractDigits = (value: string) => value.replace(/\D/g, '');
export default function ReKycScreen({ route, navigation }: Props) {
const theme = useTheme();
const accounts = useAppSelector((state) => state.account.accounts);
const profile = useAppSelector((state) => state.user.profile);
const account = accounts.find((item) => item.id === route.params?.accountId) ?? accounts[0];
// Read from the bank on mount; nothing about accounts is cached locally.
// Re-KYC is raised against an operative account.
const { operableAccounts: accounts } = useAccounts();
const account =
accounts.find((item) => item.accountNumber === route.params?.accountNumber) ?? accounts[0];
const [aadhaarNumber, setAadhaarNumber] = useState('');
const [selectedFields, setSelectedFields] = useState<ChangeField[]>([]);
@@ -59,9 +75,26 @@ export default function ReKycScreen({ route, navigation }: Props) {
const [updatedEmail, setUpdatedEmail] = useState('');
const [updatedAddress, setUpdatedAddress] = useState('');
const [consentGiven, setConsentGiven] = useState(false);
const [uploadedDocs, setUploadedDocs] = useState<string[]>([]);
const [photoUploaded, setPhotoUploaded] = useState(false);
const [acceptedDeclarations, setAcceptedDeclarations] = useState<number[]>([]);
const [otpSheetVisible, setOtpSheetVisible] = useState(false);
const [otpValue, setOtpValue] = useState('');
const toggleDocument = (id: string) => {
setUploadedDocs((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id],
);
};
const toggleDeclaration = (index: number) => {
setAcceptedDeclarations((current) =>
current.includes(index) ? current.filter((item) => item !== index) : [...current, index],
);
};
const allDeclarationsAccepted = acceptedDeclarations.length === DECLARATIONS.length;
if (!account) {
return (
<ScreenShell title=" Aadhaar" subtitle="Identity refresh">
@@ -137,6 +170,16 @@ export default function ReKycScreen({ route, navigation }: Props) {
return;
}
if (!photoUploaded) {
Alert.alert('Photograph required', 'Please upload your latest photograph to continue.');
return;
}
if (!allDeclarationsAccepted) {
Alert.alert('Self declaration', 'Please accept all the self-declaration statements to continue.');
return;
}
setOtpSheetVisible(true);
};
@@ -165,7 +208,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
return (
<ScreenShell
title="Re-KYC via Aadhaar"
subtitle={`${account.type} account · A/c ${account.maskedNumber}`}
subtitle={`${account?.accountType ?? ""} account · A/c XXXX ${account?.accountNumber.slice(-4) ?? ""}`}
contentContainerStyle={styles.contentContainer}
>
<LinearGradient
@@ -358,6 +401,145 @@ export default function ReKycScreen({ route, navigation }: Props) {
) : null}
</Card>
{/* Document upload */}
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.success}12` }]}>
<Ionicons name="cloud-upload-outline" size={18} color={theme.colors.success} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Upload documents</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Attach the latest copies of your KYC documents
</Text>
</View>
</View>
{DOCUMENT_OPTIONS.map((doc) => {
const isUploaded = uploadedDocs.includes(doc.id);
return (
<Pressable
key={doc.id}
onPress={() => toggleDocument(doc.id)}
style={[
styles.uploadRow,
{
backgroundColor: isUploaded ? `${theme.colors.success}10` : theme.colors.surfaceMuted,
borderColor: isUploaded ? `${theme.colors.success}45` : theme.colors.cardBorder,
},
]}
>
<View style={[styles.uploadIcon, { backgroundColor: theme.colors.surface }]}>
<Ionicons name={doc.icon} size={18} color={theme.colors.primary} />
</View>
<View style={styles.uploadCopy}>
<Text style={[styles.uploadLabel, { color: theme.colors.textPrimary }]}>{doc.label}</Text>
<Text style={[styles.uploadHint, { color: theme.colors.textSecondary }]}>
{isUploaded ? 'Uploaded · tap to remove' : doc.hint}
</Text>
</View>
<Ionicons
name={isUploaded ? 'checkmark-circle' : 'add-circle-outline'}
size={22}
color={isUploaded ? theme.colors.success : theme.colors.primary}
/>
</Pressable>
);
})}
</Card>
{/* Latest photograph */}
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.info}12` }]}>
<Ionicons name="camera-outline" size={18} color={theme.colors.info} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Latest photograph</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Upload a clear, recent passport-style photo
</Text>
</View>
</View>
<Pressable
onPress={() => setPhotoUploaded((value) => !value)}
style={[
styles.photoTile,
{
backgroundColor: photoUploaded ? `${theme.colors.success}10` : theme.colors.surfaceMuted,
borderColor: photoUploaded ? `${theme.colors.success}45` : theme.colors.cardBorder,
},
]}
>
<Ionicons
name={photoUploaded ? 'checkmark-circle' : 'person-circle-outline'}
size={48}
color={photoUploaded ? theme.colors.success : theme.colors.textMuted}
/>
<Text style={[styles.photoText, { color: photoUploaded ? theme.colors.success : theme.colors.textSecondary }]}>
{photoUploaded ? 'Photograph uploaded' : 'Tap to capture / upload photo'}
</Text>
</Pressable>
</Card>
{/* Self declaration */}
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.warning}12` }]}>
<Ionicons name="document-text-outline" size={18} color={theme.colors.warning} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Self declaration</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Please read and accept all statements
</Text>
</View>
</View>
{DECLARATIONS.map((statement, index) => {
const accepted = acceptedDeclarations.includes(index);
return (
<Pressable
key={statement}
onPress={() => toggleDeclaration(index)}
style={[
styles.consentCard,
{ backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder },
]}
>
<Ionicons
name={accepted ? 'checkmark-circle' : 'ellipse-outline'}
size={22}
color={accepted ? theme.colors.success : theme.colors.textMuted}
/>
<Text style={[styles.consentText, { color: theme.colors.textPrimary }]}>{statement}</Text>
</Pressable>
);
})}
</Card>
{/* Video KYC option */}
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="videocam-outline" size={18} color={theme.colors.primary} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Prefer a video call?</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Complete re-KYC over a secure video call (V-CIP)
</Text>
</View>
</View>
<Button
title="Start Video KYC"
variant="secondary"
onPress={() => account && navigation.navigate('VideoKyc', { accountNumber: account.accountNumber })}
leftAccessory={<Ionicons name="videocam-outline" size={18} color={theme.colors.textPrimary} />}
/>
</Card>
<Card style={styles.footerCard}>
<View style={styles.footerHeader}>
<Ionicons name="time-outline" size={18} color={theme.colors.primary} />
@@ -429,7 +611,7 @@ const styles = StyleSheet.create({
},
heroTitle: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.4,
},
heroSubtitle: {
@@ -453,12 +635,12 @@ const styles = StyleSheet.create({
},
stepIndexText: {
fontSize: 12,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
stepText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
sectionCard: {
padding: 20,
@@ -482,12 +664,12 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
sectionSubtitle: {
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
consentCard: {
flexDirection: 'row',
@@ -497,11 +679,51 @@ const styles = StyleSheet.create({
borderRadius: 16,
padding: 16,
},
uploadRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderWidth: 1,
borderRadius: 16,
padding: 14,
},
uploadIcon: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
uploadCopy: {
flex: 1,
gap: 2,
},
uploadLabel: {
fontSize: 14,
fontFamily: fontFamily.bold,
},
uploadHint: {
fontSize: 12,
fontFamily: fontFamily.medium,
},
photoTile: {
alignItems: 'center',
justifyContent: 'center',
gap: 10,
borderWidth: 1,
borderStyle: 'dashed',
borderRadius: 16,
paddingVertical: 28,
},
photoText: {
fontSize: 13,
fontFamily: fontFamily.bold,
},
consentText: {
flex: 1,
fontSize: 13,
lineHeight: 19,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
chipGrid: {
flexDirection: 'row',
@@ -520,7 +742,7 @@ const styles = StyleSheet.create({
},
changeChipText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
footerCard: {
padding: 20,
@@ -533,7 +755,7 @@ const styles = StyleSheet.create({
},
footerTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
footerText: {
fontSize: 13,
@@ -553,7 +775,7 @@ const styles = StyleSheet.create({
},
emptyTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
emptyDescription: {
fontSize: 14,
+164
View File
@@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, Pressable } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { HomeStackParamList } from '../../../navigation/types';
import { useTheme, fontFamily } from '../../../theme';
type Props = NativeStackScreenProps<HomeStackParamList, 'VideoKyc'>;
type IconName = React.ComponentProps<typeof Ionicons>['name'];
type Slot = { id: string; label: string; sub: string };
const SLOTS: Slot[] = [
{ id: 'now', label: 'Connect now', sub: 'Avg. wait ~2 min' },
{ id: 'today_4', label: 'Today, 4:00 PM', sub: 'Scheduled' },
{ id: 'tomorrow_11', label: 'Tomorrow, 11:00 AM', sub: 'Scheduled' },
];
const CHECKLIST: { id: string; label: string; icon: IconName }[] = [
{ id: 'pan', label: 'Original PAN card in hand', icon: 'card-outline' },
{ id: 'light', label: 'Well-lit room, face clearly visible', icon: 'sunny-outline' },
{ id: 'network', label: 'Stable internet connection', icon: 'wifi-outline' },
{ id: 'camera', label: 'Camera & microphone permission', icon: 'videocam-outline' },
];
export default function VideoKycScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const [selectedSlot, setSelectedSlot] = useState<string>('now');
const [agreed, setAgreed] = useState(false);
const handleStart = () => {
if (!agreed) {
toast('Consent required', 'error', 'Please agree to the V-CIP guidelines to continue.');
return;
}
const slot = SLOTS.find((item) => item.id === selectedSlot);
if (selectedSlot === 'now') {
toast('Connecting…', 'info', 'Please wait while we connect you to a KYC officer.');
} else {
toast('Slot booked', 'success', `Your video KYC is scheduled for ${slot?.label}.`);
}
setTimeout(() => navigation.goBack(), 1200);
};
return (
<ScreenShell title="Video KYC" subtitle="Verify your identity over a video call" contentContainerStyle={styles.content}>
{/* Hero */}
<Card style={styles.heroCard}>
<View style={[styles.heroIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="videocam-outline" size={26} color={theme.colors.primary} />
</View>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>Video-based Customer Identification</Text>
<Text style={[styles.heroSub, { color: theme.colors.textSecondary }]}>
A bank officer will verify your identity on a short, secure video call. Keep your PAN ready.
</Text>
<View style={styles.placeholderPreview}>
<Ionicons name="person-circle-outline" size={56} color={theme.colors.textMuted} />
<Text style={[styles.previewText, { color: theme.colors.textMuted }]}>Camera preview</Text>
</View>
</Card>
{/* Checklist */}
<Card style={styles.sectionCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Before you start</Text>
{CHECKLIST.map((item) => (
<View key={item.id} style={styles.checkRow}>
<View style={[styles.checkIcon, { backgroundColor: `${theme.colors.info}12` }]}>
<Ionicons name={item.icon} size={16} color={theme.colors.info} />
</View>
<Text style={[styles.checkText, { color: theme.colors.textPrimary }]}>{item.label}</Text>
</View>
))}
</Card>
{/* Slots */}
<Card style={styles.sectionCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Choose a slot</Text>
{SLOTS.map((slot) => {
const isSelected = slot.id === selectedSlot;
return (
<Pressable
key={slot.id}
onPress={() => setSelectedSlot(slot.id)}
style={[
styles.slotRow,
{
backgroundColor: isSelected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
borderColor: isSelected ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
<Ionicons
name={isSelected ? 'radio-button-on' : 'radio-button-off'}
size={20}
color={isSelected ? theme.colors.primary : theme.colors.textMuted}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.slotLabel, { color: theme.colors.textPrimary }]}>{slot.label}</Text>
<Text style={[styles.slotSub, { color: theme.colors.textSecondary }]}>{slot.sub}</Text>
</View>
</Pressable>
);
})}
</Card>
{/* Consent */}
<Pressable
onPress={() => setAgreed((value) => !value)}
style={[styles.consentRow, { backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder }]}
>
<Ionicons
name={agreed ? 'checkmark-circle' : 'ellipse-outline'}
size={22}
color={agreed ? theme.colors.success : theme.colors.textMuted}
/>
<Text style={[styles.consentText, { color: theme.colors.textPrimary }]}>
I consent to a recorded video call for KYC verification as per RBI V-CIP guidelines.
</Text>
</Pressable>
<Button
title={selectedSlot === 'now' ? 'Start video call' : 'Book slot'}
onPress={handleStart}
leftAccessory={<Ionicons name="videocam" size={18} color={theme.colors.white} />}
/>
<View style={{ height: 40 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
content: { gap: 16 },
heroCard: { padding: 20, gap: 12, alignItems: 'center' },
heroIcon: { width: 56, height: 56, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
heroTitle: { fontSize: 18, fontFamily: fontFamily.bold, textAlign: 'center' },
heroSub: { fontSize: 13, lineHeight: 19, textAlign: 'center' },
placeholderPreview: {
width: '100%',
height: 140,
borderRadius: 16,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(0,0,0,0.12)',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
marginTop: 4,
},
previewText: { fontSize: 12, fontFamily: fontFamily.semibold },
sectionCard: { padding: 18, gap: 14 },
sectionTitle: { fontSize: 16, fontFamily: fontFamily.bold },
checkRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
checkIcon: { width: 32, height: 32, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
checkText: { flex: 1, fontSize: 13, fontFamily: fontFamily.semibold },
slotRow: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 1, borderRadius: 14, padding: 14 },
slotLabel: { fontSize: 14, fontFamily: fontFamily.bold },
slotSub: { fontSize: 12, fontFamily: fontFamily.medium },
consentRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 12, borderWidth: 1, borderRadius: 16, padding: 16 },
consentText: { flex: 1, fontSize: 13, lineHeight: 19, fontFamily: fontFamily.medium },
});
+161
View File
@@ -0,0 +1,161 @@
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { endpoints } from '../../../services/api/endpoints';
import { resolveLive } from '../../../services/api/serviceHelpers';
import { downloadPdf } from '../../../services/files/pdfDownload';
import type {
CbsAccount,
CbsPdfDocument,
CbsStatementExportQuery,
CbsStatementPage,
CbsStatementQuery,
MmidAccountsResult,
} from '../../../types/cbs';
export const accountsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
/**
* Every account on the signed-in profile. No parameters — the profile comes
* entirely from the session.
*
* This is the app's single source of account data. `getMmidAccounts`
* below covers the same ground in masked form, and is deliberately not a
* substitute: two sources for the same thing is how a screen ends up
* showing an account list that disagrees with the one next to it. Anything
* the app acts on — a payer account, a statement lookup, a cheque — comes
* from here.
*
* `keepUnusedDataFor: 0` is the point of this endpoint's configuration, not
* a tuning knob. The moment the last screen showing an account unmounts,
* the cache entry is dropped, so the next screen that needs one asks the
* bank again rather than reading a copy of unknown age. Paired with
* `refetchOnMountOrArgChange` at the call site — see `useAccounts` — that
* means the numbers on screen were fetched for that screen.
*
* Nothing here is written to disk. RTK Query's cache is in memory and the
* store is not persisted, so the unmasked account numbers this returns die
* with the process.
*/
getAccounts: builder.query<CbsAccount[], void>({
queryFn: () =>
resolveLive(async () => {
const response = await apiClient.get<CbsAccount[]>(endpoints.accounts.list);
return response.data;
}),
providesTags: ['Accounts'],
keepUnusedDataFor: 0,
}),
/**
* One page of an account's ledger.
*
* `fromDate` and `toDate` are dropped unless both are set. The bank
* answers `INVALID_DATE_RANGE` when it gets exactly one, and a screen with
* a half-filled range picker would otherwise send that on every keystroke.
* Sending neither is the supported case — the bank then picks its own
* recent window.
*
* Cached per distinct query, so paging back to a page already fetched does
* not re-hit the bank, but `keepUnusedDataFor: 0` still drops the lot the
* moment the screen unmounts: this response carries balances, and the
* account data rule is that what is on screen was fetched for that screen.
*/
getStatement: builder.query<CbsStatementPage, CbsStatementQuery>({
queryFn: ({ accountNumber, fromDate, toDate, page, size }) =>
resolveLive(async () => {
const hasRange = Boolean(fromDate && toDate);
const response = await apiClient.get<CbsStatementPage>(
endpoints.accounts.statement,
{
params: {
accountNumber,
...(hasRange ? { fromDate, toDate } : {}),
...(page === undefined ? {} : { page }),
...(size === undefined ? {} : { size }),
},
},
);
return response.data;
}),
providesTags: ['Accounts'],
keepUnusedDataFor: 0,
}),
/**
* The customer's accounts and MMIDs as the bank masks them, for the screen
* that shows someone else what to pay into.
*
* Overlaps `getAccounts` and is not a substitute for it — that endpoint is
* still the single source for anything the app *acts* on, and mixing the
* two is how one screen ends up disagreeing with the next. This exists for
* the one job `getAccounts` cannot do: hand over a number safely. It pairs
* the bank's own masked form with the mobile number a remitter needs
* alongside the MMID, so nothing here has to be derived from an unmasked
* number the screen would then have to be careful with.
*/
getMmidAccounts: builder.query<MmidAccountsResult, void>({
queryFn: () =>
resolveLive(async () => {
const response = await apiClient.get<MmidAccountsResult>(endpoints.accounts.mmid);
return response.data;
}),
providesTags: ['Accounts'],
keepUnusedDataFor: 0,
}),
/**
* The statement for a range, as a PDF on disk.
*
* A mutation rather than a query even though it is a GET: it writes a file
* and is fired by a button, and caching the resulting path would hand the
* next caller a file that may since have been swept from the cache
* directory. Same date-pair rule as `getStatement` — both or neither.
*
* The bank reuses the cached fetch behind `getStatement`, so exporting a
* range the customer has just been reading costs no extra call upstream.
*/
exportStatement: builder.mutation<CbsPdfDocument, CbsStatementExportQuery>({
queryFn: ({ accountNumber, fromDate, toDate }) =>
resolveLive(() => {
const hasRange = Boolean(fromDate && toDate);
return downloadPdf({
path: endpoints.accounts.statementExport,
params: {
accountNumber,
...(hasRange ? { fromDate, toDate } : {}),
},
fallbackFileName: `statement-${accountNumber}.pdf`,
});
}),
}),
/**
* The last 10 entries as a short PDF, led by the current available balance
* — the same figure an ATM mini-statement prints at the top. No date range
* exists on this one; asking for a period is what `exportStatement` is for.
*/
downloadMiniStatement: builder.mutation<CbsPdfDocument, { accountNumber: string }>({
queryFn: ({ accountNumber }) =>
resolveLive(() =>
downloadPdf({
path: endpoints.accounts.miniStatement,
params: { accountNumber },
fallbackFileName: `mini-statement-${accountNumber}.pdf`,
}),
),
}),
}),
});
export const {
useGetAccountsQuery,
useGetMmidAccountsQuery,
useGetStatementQuery,
useExportStatementMutation,
useDownloadMiniStatementMutation,
} = accountsApi;
@@ -1,16 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../../../app/store';
export const selectAccountState = (state: RootState) => state.account;
export const selectSelectedAccount = createSelector(
[selectAccountState],
(accountState) =>
accountState.accounts.find((account) => account.id === accountState.selectedAccountId) ??
accountState.accounts[0],
);
export const selectRecentTransactions = (state: RootState) => state.account.transactions;
export const selectDashboardShortcuts = (state: RootState) => state.account.quickActions;
export const selectAllAccounts = (state: RootState) => state.account.accounts;
+13 -34
View File
@@ -4,15 +4,21 @@ import type { DashboardPayload } from '../../../types/app';
import { demoDashboard } from '../../../constants/mockData';
import { seedDashboardState } from './accountThunk';
interface AccountState extends DashboardPayload {
selectedAccountId: string | null;
isBalanceHidden: boolean;
}
/**
* Dashboard fixtures — transactions, insights, spending, shortcuts.
*
* Accounts are deliberately absent. They are read from `GET /accounts` at the
* point of use and never held here: this store is the app's own memory, and an
* account list kept in it is a copy of unknown age that some screen will
* eventually render as though it were current.
*/
type AccountState = Omit<DashboardPayload, 'accounts'>;
const initialState: AccountState = {
...demoDashboard,
selectedAccountId: demoDashboard.accounts[0]?.id ?? null,
isBalanceHidden: false,
transactions: demoDashboard.transactions,
insights: demoDashboard.insights,
spending: demoDashboard.spending,
quickActions: demoDashboard.quickActions,
};
const accountSlice = createSlice({
@@ -20,41 +26,14 @@ const accountSlice = createSlice({
initialState,
reducers: {
hydrateDashboard(state, action: PayloadAction<DashboardPayload>) {
state.accounts = action.payload.accounts;
state.transactions = action.payload.transactions;
state.insights = action.payload.insights;
state.spending = action.payload.spending;
state.quickActions = action.payload.quickActions;
state.selectedAccountId = action.payload.accounts[0]?.id ?? null;
},
setSelectedAccount(state, action: PayloadAction<string>) {
state.selectedAccountId = action.payload;
},
toggleBalanceHidden(state) {
state.isBalanceHidden = !state.isBalanceHidden;
},
deductBalance(state, action: PayloadAction<number>) {
const account = state.accounts.find(a => a.id === state.selectedAccountId);
if (account) {
account.currentBalance -= action.payload;
}
},
addAccount(state, action: PayloadAction<any>) {
state.accounts.push(action.payload);
if (!state.selectedAccountId) {
state.selectedAccountId = action.payload.id;
}
},
removeAccount(state, action: PayloadAction<string>) {
state.accounts = state.accounts.filter(a => a.id !== action.payload);
if (state.selectedAccountId === action.payload) {
state.selectedAccountId = state.accounts[0]?.id ?? null;
}
},
},
extraReducers: (builder) => {
builder.addCase(seedDashboardState.fulfilled, (state, action) => {
state.accounts = action.payload.accounts;
state.transactions = action.payload.transactions;
state.insights = action.payload.insights;
state.spending = action.payload.spending;
@@ -0,0 +1,99 @@
import {
accountTypeLabel,
isOperativeAccount,
maskAccountNumber,
} from './accountRules';
import type { CbsAccount } from '../../../types/cbs';
/**
* The four accounts `GET /accounts` returns for a real profile, copied from a
* live response. Two operative accounts and two deposits, which is the shape
* the picker rules exist for.
*/
const LIVE_ACCOUNTS: CbsAccount[] = [
{
accountNumber: '70108103544967',
accountType: 'FIXED_DEPOSIT',
status: 'ACTIVE',
ifsc: 'SIM0000001',
mmid: null,
},
{
accountNumber: '60108103544967',
accountType: 'CURRENT',
status: 'ACTIVE',
ifsc: 'SIM0000001',
mmid: '1072199',
},
{
accountNumber: '50108103544967',
accountType: 'SAVINGS',
status: 'ACTIVE',
ifsc: 'SIM0000001',
mmid: '1072220',
},
{
accountNumber: '80108103544967',
accountType: 'RECURRING_DEPOSIT',
status: 'ACTIVE',
ifsc: 'SIM0000001',
mmid: null,
},
];
describe('isOperativeAccount', () => {
it('offers only the savings and current accounts from a live profile', () => {
expect(LIVE_ACCOUNTS.filter(isOperativeAccount).map((a) => a.accountType)).toEqual([
'CURRENT',
'SAVINGS',
]);
});
it.each(['FIXED_DEPOSIT', 'RECURRING_DEPOSIT'] as const)(
'never offers a %s, even when it is active',
(accountType) => {
expect(
isOperativeAccount({
accountNumber: '70108103544967',
accountType,
status: 'ACTIVE',
ifsc: 'SIM0000001',
mmid: null,
}),
).toBe(false);
},
);
// A dormant savings account is still the customer's, and still worth listing
// on an enquiry screen — but a transfer debited from it would be refused.
it.each(['DORMANT', 'BLOCKED', 'CLOSED'] as const)(
'does not offer a savings account that is %s',
(status) => {
expect(
isOperativeAccount({
accountNumber: '50108103544967',
accountType: 'SAVINGS',
status,
ifsc: 'SIM0000001',
mmid: '1072220',
}),
).toBe(false);
},
);
});
describe('accountTypeLabel', () => {
it('turns the API_SHAPE into something readable', () => {
expect(accountTypeLabel('FIXED_DEPOSIT')).toBe('Fixed deposit');
expect(accountTypeLabel('RECURRING_DEPOSIT')).toBe('Recurring deposit');
expect(accountTypeLabel('SAVINGS')).toBe('Savings');
});
});
describe('maskAccountNumber', () => {
// The unmasked number arrives only from `GET /accounts`, and must not reach
// the screen except where the customer is confirming it.
it('keeps only the last four digits', () => {
expect(maskAccountNumber('50108103544967')).toBe('XXXX 4967');
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { CbsAccount, CbsAccountType } from '../../../types/cbs';
/**
* Rules about what an account *is*, kept apart from the hook that fetches them
* so they carry no transport imports and can be reasoned about — and tested —
* on their own.
*/
/**
* Account types that can take part in a transaction.
*
* Deposit accounts are excluded on purpose. A fixed or recurring deposit cannot
* be debited for a transfer, cannot have a cheque drawn on it and cannot
* receive an IMPS credit — the bank refuses all three. Offering one in a picker
* only lets the customer choose a path that ends in a refusal they cannot act
* on, so they are never offered as a choice. They are still shown wherever a
* screen is listing what the customer holds.
*/
export const OPERATIVE_ACCOUNT_TYPES: readonly CbsAccountType[] = ['SAVINGS', 'CURRENT'];
/** True when this account may be offered as a choice for an action. */
export const isOperativeAccount = (account: CbsAccount) =>
account.status === 'ACTIVE' && OPERATIVE_ACCOUNT_TYPES.includes(account.accountType);
/**
* Masked form of a full account number, in the shape the rest of the app uses.
*
* `GET /accounts` is the only endpoint that hands back an unmasked number, so
* this is what stands between that and everything that renders one. Show the
* full number only where the customer is being asked to confirm it.
*/
export const maskAccountNumber = (accountNumber: string) =>
`XXXX ${accountNumber.slice(-4)}`;
/**
* Readable label for an account type.
*
* The API returns SCREAMING_SNAKE, so `FIXED_DEPOSIT` has to become "Fixed
* deposit" rather than "Fixed_deposit" — the underscore is what a naive
* title-case leaves behind.
*/
export const accountTypeLabel = (accountType: string) => {
const [first = '', ...rest] = accountType.toLowerCase().split('_');
return [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(' ');
};
+112
View File
@@ -0,0 +1,112 @@
import type { CbsStatementTransaction } from '../../../types/cbs';
import {
counterpartyDirection,
counterpartyLabel,
hasCounterparty,
statementDay,
statementTime,
} from './statement';
const row = (overrides: Partial<CbsStatementTransaction> = {}): CbsStatementTransaction => ({
transactionDate: '2026-09-17T10:07:11',
narration: 'Clearing - Outward Cheque',
referenceNumber: 'TXN5123450032',
transactionType: 'CLEARING_OUTWARD',
drCr: 'DR',
amount: 2693,
balanceAfter: 9355,
counterpartyName: 'Priya Nair',
counterpartyAccountNumber: '91449252878981',
counterpartyIfsc: 'HDFC0001234',
counterpartyBankName: 'HDFC Bank',
counterpartyVpa: null,
...overrides,
});
/**
* `transactionDate` gained a time component, and five counterparty fields
* arrived alongside it. These pin the two things that broke or became possible
* as a result: day grouping, which used to key on the whole value, and naming
* the other side of an entry without ever printing a full account number.
*/
describe('statementDay', () => {
it('takes the date off a value that now carries a time', () => {
expect(statementDay(row())).toBe('2026-09-17');
});
it('buckets two entries booked on the same day together', () => {
// The regression this exists for: keying on the raw `transactionDate` gave
// each of these its own group, and the list drew a day header per row.
expect(statementDay(row({ transactionDate: '2026-09-17T10:07:11' }))).toBe(
statementDay(row({ transactionDate: '2026-09-17T18:44:02' })),
);
});
it('is unchanged by a bank still sending a bare date', () => {
expect(statementDay(row({ transactionDate: '2026-09-17' }))).toBe('2026-09-17');
});
// Sliced, never parsed: `new Date('2026-09-17')` is read as UTC, which moves
// the row to the 16th for every customer of this bank at +05:30.
it('does not shift the day across a timezone', () => {
expect(statementDay(row({ transactionDate: '2026-01-01T00:30:00' }))).toBe('2026-01-01');
});
});
describe('statementTime', () => {
it('reads the booking time', () => {
expect(statementTime(row())).toBe('10:07');
});
it('is null rather than midnight when the bank sent no time', () => {
expect(statementTime(row({ transactionDate: '2026-09-17' }))).toBeNull();
});
});
describe('counterparty', () => {
it('is absent for the channels that have no other party', () => {
// All five fields are null together for these, by contract — so blank is
// the right answer for them, not missing data.
const cash = row({
transactionType: 'CASH_DEPOSIT',
counterpartyName: null,
counterpartyAccountNumber: null,
counterpartyIfsc: null,
counterpartyBankName: null,
counterpartyVpa: null,
});
expect(hasCounterparty(cash)).toBe(false);
expect(counterpartyLabel(cash)).toBeNull();
});
it('prefers the name, which is what a customer recognises', () => {
expect(counterpartyLabel(row())).toBe('Priya Nair');
});
it('falls back to the VPA for a UPI entry the bank did not name', () => {
expect(
counterpartyLabel(
row({
transactionType: 'UPI',
counterpartyName: null,
counterpartyVpa: 'anita.iyer255@okhdfcbank',
}),
),
).toBe('anita.iyer255@okhdfcbank');
});
it('never prints a counterparty account number in full', () => {
const label = counterpartyLabel(
row({ counterpartyName: null, counterpartyVpa: null }),
);
expect(label).toBe('••••8981');
expect(label).not.toContain('91449252878981');
});
it('says which way the money went', () => {
expect(counterpartyDirection(row({ drCr: 'CR' }))).toBe('From');
expect(counterpartyDirection(row({ drCr: 'DR' }))).toBe('To');
});
});
+79
View File
@@ -0,0 +1,79 @@
import type { CbsStatementTransaction } from '../../../types/cbs';
/**
* Rules for reading a statement row, kept free of transport and React imports
* so they can be tested without standing up the API layer or a renderer.
*/
/**
* The calendar day a row was booked on, as `yyyy-MM-dd`.
*
* `transactionDate` used to be exactly this and is now
* `yyyy-MM-ddTHH:mm:ss`, so anything that groups or compares rows by day has
* to take the date part off first — otherwise every entry buckets on its own
* and the list renders one day header per transaction.
*
* Sliced rather than parsed through `Date`: the value is already local time,
* and `new Date(...)` on a bare `yyyy-MM-dd` is read as UTC, which would move
* a row to the previous day for every customer of this bank at +05:30.
*/
export const statementDay = (transaction: CbsStatementTransaction) =>
transaction.transactionDate.slice(0, 10);
/**
* The time of day a row was booked, as `HH:mm`, or null for a bank still
* sending a bare date.
*
* Nullable on purpose. The time arrived with a contract change, and a row that
* predates it — or comes from a bank that truncates — must render without one
* rather than showing `00:00`, which reads as midnight rather than as unknown.
*/
export const statementTime = (transaction: CbsStatementTransaction) => {
const time = transaction.transactionDate.slice(11, 16);
return /^\d{2}:\d{2}$/.test(time) ? time : null;
};
/**
* True when the bank named the other side of the entry.
*
* All four counterparty fields are null together for `ATM_WDL`, `POS` and
* `CASH_DEPOSIT` — those have no other party's account — so an absent
* counterparty is the right answer for them, not missing data, and the row
* should simply omit the line rather than show a placeholder.
*/
export const hasCounterparty = (transaction: CbsStatementTransaction) =>
Boolean(
transaction.counterpartyName ||
transaction.counterpartyAccountNumber ||
transaction.counterpartyVpa,
);
/**
* One line naming the other party, or null when there is none to name.
*
* Prefers the name, because that is what a customer recognises. A UPI entry
* falls back to the VPA, which is its only human-readable handle; everything
* else falls back to the masked tail of the account number, never the whole
* thing — a counterparty's full account number has no business on a list row.
*/
export const counterpartyLabel = (transaction: CbsStatementTransaction) => {
if (transaction.counterpartyName) {
return transaction.counterpartyName;
}
if (transaction.counterpartyVpa) {
return transaction.counterpartyVpa;
}
const account = transaction.counterpartyAccountNumber;
return account ? `••••${account.slice(-4)}` : null;
};
/**
* Where the money went, or came from, as a prefix for the counterparty line.
* `CR` means it arrived, so the counterparty is the sender.
*/
export const counterpartyDirection = (transaction: CbsStatementTransaction) =>
transaction.drCr === 'CR' ? 'From' : 'To';

Some files were not shown because too many files have changed in this diff Show More