Update CBS Mobile App codebase
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 & 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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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'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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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'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'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'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,
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(' ');
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user