Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
@@ -0,0 +1,377 @@
import React, { useEffect, useMemo, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { AccountSelector, Button, Card, Input, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTransferForm } from '../hooks/useTransferForm';
import { TransferAmountFields } from '../components/TransferAmountFields';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import { isValidAccountNumber, isValidIfsc } from '../../../utils/validation';
import { useTheme, fontFamily } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferToAccount'>;
/** Rails this screen can offer, narrowest description first. */
const RAILS = [
{ id: 'IMPS' as const, feature: FeatureKey.PAYMENTS_TRANSFER_IMPS, label: 'IMPS', hint: 'Instant, 24×7' },
{ id: 'NEFT' as const, feature: FeatureKey.PAYMENTS_TRANSFER_NEFT, label: 'NEFT', hint: 'Half-hourly batches' },
{ id: 'RTGS' as const, feature: FeatureKey.PAYMENTS_TRANSFER_RTGS, label: 'RTGS', hint: 'High value, same day' },
];
/** Below this an RTGS transfer is refused by the network, not by us. */
const RTGS_MINIMUM = 200000;
/**
* Send to an account number and IFSC.
*
* The account number is asked for twice. It is the one field on this screen
* with no check digit and no lookup behind it — an IFSC is structured and a
* name comes back from the bank, but a mistyped account number is a valid
* account number belonging to someone else.
*/
export default function TransferToAccountScreen({ navigation, route }: Props) {
const theme = useTheme();
const prefillId = route.params?.beneficiaryId;
const beneficiary = useAppSelector(selectBeneficiaryById(prefillId ?? ''));
const impsEnabled = useFeature(FeatureKey.PAYMENTS_TRANSFER_IMPS);
const neftEnabled = useFeature(FeatureKey.PAYMENTS_TRANSFER_NEFT);
const rtgsEnabled = useFeature(FeatureKey.PAYMENTS_TRANSFER_RTGS);
const availableRails = useMemo(
() =>
RAILS.filter((rail) =>
rail.id === 'IMPS' ? impsEnabled : rail.id === 'NEFT' ? neftEnabled : rtgsEnabled,
),
[impsEnabled, neftEnabled, rtgsEnabled],
);
const {
accounts,
sourceAccountNumber,
setSourceAccountNumber,
amount,
handleAmountChange,
parsedAmount,
amountError,
amountValid,
remarks,
setRemarks,
} = useTransferForm();
const [name, setName] = useState('');
const [accountNumber, setAccountNumber] = useState('');
const [confirmAccountNumber, setConfirmAccountNumber] = useState('');
const [ifsc, setIfsc] = useState('');
const [rail, setRail] = useState<'IMPS' | 'NEFT' | 'RTGS'>(availableRails[0]?.id ?? 'IMPS');
const [saveAsBeneficiary, setSaveAsBeneficiary] = useState(false);
// Keep the choice on a rail that is actually offered — a switch flipped in
// remote config must not leave a disabled rail selected underneath.
useEffect(() => {
const fallback = availableRails[0];
if (fallback && !availableRails.some((entry) => entry.id === rail)) {
setRail(fallback.id);
}
}, [availableRails, rail]);
useEffect(() => {
if (!beneficiary) {
return;
}
setName(beneficiary.name);
setIfsc(beneficiary.ifsc ?? '');
// A saved payee is stored masked, so there is no number to prefill. The
// customer confirms it once and it is theirs to keep.
setSaveAsBeneficiary(false);
}, [beneficiary]);
const accountError =
accountNumber.length > 0 && !isValidAccountNumber(accountNumber)
? 'Enter a 9-18 digit account number'
: undefined;
const confirmError =
confirmAccountNumber.length > 0 && confirmAccountNumber !== accountNumber
? 'Both entries must match'
: undefined;
const ifscError =
ifsc.length > 0 && !isValidIfsc(ifsc) ? 'Enter a valid IFSC code' : undefined;
/** The only rail on offer, when there is exactly one. */
const soleRail = availableRails.length === 1 ? availableRails[0] : undefined;
const railError =
rail === 'RTGS' && amountValid && parsedAmount < RTGS_MINIMUM
? `RTGS requires at least ₹${RTGS_MINIMUM.toLocaleString('en-IN')}`
: undefined;
const canContinue =
Boolean(sourceAccountNumber) &&
name.trim().length > 0 &&
isValidAccountNumber(accountNumber) &&
confirmAccountNumber === accountNumber &&
isValidIfsc(ifsc) &&
amountValid &&
!railError;
const handleContinue = () => {
if (!canContinue || !sourceAccountNumber) {
return;
}
navigation.navigate('PaymentConfirmation', {
transferType: rail,
amount: parsedAmount,
remarks: remarks || undefined,
sourceAccountNumber,
beneficiaryId: prefillId,
saveRecipient: saveAsBeneficiary,
recipient: {
name: name.trim(),
// Only the last four ever reach the receipt or the payee list.
accountMasked: `XXXX ${accountNumber.slice(-4)}`,
accountNumber,
ifsc: ifsc.toUpperCase(),
},
});
};
return (
<ScreenShell
title="To account number"
subtitle="Any bank, by account and IFSC"
contentContainerStyle={styles.container}
fixedFooter={
<View style={[styles.footer, { borderTopColor: theme.colors.cardBorder }]}>
<Button title="Continue" onPress={handleContinue} disabled={!canContinue} />
</View>
}
>
<Card style={styles.card}>
<AccountSelector
label="Debit from"
accounts={accounts}
selectedAccountNumber={sourceAccountNumber}
onSelect={setSourceAccountNumber}
/>
</Card>
<Card style={styles.card}>
<Text style={[styles.sectionLabel, { color: theme.colors.primary }]}>RECIPIENT</Text>
<Input
label="Beneficiary name"
placeholder="As per bank records"
value={name}
onChangeText={setName}
maxLength={60}
/>
<Input
label="Account number"
placeholder="Enter 9-18 digit account number"
keyboardType="number-pad"
maxLength={18}
value={accountNumber}
onChangeText={(value) => setAccountNumber(value.replace(/\D/g, ''))}
error={accountError}
/>
<Input
label="Confirm account number"
placeholder="Re-enter account number"
keyboardType="number-pad"
maxLength={18}
// Pasting the first field into the second defeats the point of
// asking twice, so the OS paste affordance is withheld here.
contextMenuHidden
value={confirmAccountNumber}
onChangeText={(value) => setConfirmAccountNumber(value.replace(/\D/g, ''))}
error={confirmError}
/>
<Input
label="IFSC code"
placeholder="e.g. SBIN0001234"
autoCapitalize="characters"
autoCorrect={false}
maxLength={11}
value={ifsc}
onChangeText={(value) => setIfsc(value.replace(/[^A-Za-z0-9]/g, '').toUpperCase())}
error={ifscError}
/>
{availableRails.length > 1 ? (
<View style={styles.railBlock}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
TRANSFER RAIL
</Text>
<View style={styles.railRow}>
{availableRails.map((entry) => {
const selected = entry.id === rail;
return (
<View key={entry.id} style={styles.railItem}>
<Button
title={entry.label}
variant={selected ? 'secondary' : 'ghost'}
onPress={() => setRail(entry.id)}
accessibilityLabel={`${entry.label} — ${entry.hint}`}
style={[
styles.railButton,
{
borderColor: selected ? theme.colors.primary : theme.colors.cardBorder,
backgroundColor: selected
? theme.colors.primarySurface
: theme.colors.surface,
},
]}
/>
<Text style={[styles.railHint, { color: theme.colors.textSecondary }]}>
{entry.hint}
</Text>
</View>
);
})}
</View>
</View>
) : soleRail ? (
// One rail is not a choice. It is still stated, because the customer
// needs to know how the money travels and when it will land.
<View style={styles.railBlock}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
TRANSFER RAIL
</Text>
<View
style={[
styles.soleRail,
{
borderColor: theme.colors.primary,
backgroundColor: theme.colors.primarySurface,
},
]}
>
<Text style={[styles.soleRailLabel, { color: theme.colors.primary }]}>
{soleRail.label}
</Text>
<Text style={[styles.soleRailHint, { color: theme.colors.primary }]}>
{soleRail.hint}
</Text>
</View>
</View>
) : null}
{railError ? (
<Text style={[styles.railError, { color: theme.colors.danger }]}>{railError}</Text>
) : null}
<View style={styles.saveRow}>
<View style={styles.saveCopy}>
<Text style={[styles.saveTitle, { color: theme.colors.textPrimary }]}>
Save as beneficiary
</Text>
<Text style={[styles.saveHint, { color: theme.colors.primary }]}>
Keep this payee for future transfers
</Text>
</View>
<Switch
value={saveAsBeneficiary}
onValueChange={setSaveAsBeneficiary}
accessibilityLabel="Save as beneficiary"
/>
</View>
</Card>
<TransferAmountFields
amount={amount}
onAmountChange={handleAmountChange}
remarks={remarks}
onRemarksChange={setRemarks}
error={amountError}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
paddingBottom: 24,
},
card: {
padding: 16,
gap: 16,
},
sectionLabel: {
fontSize: 13,
fontFamily: fontFamily.bold,
letterSpacing: 0.4,
},
fieldLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
},
railBlock: {
gap: 8,
},
railRow: {
flexDirection: 'row',
gap: 10,
},
railItem: {
flex: 1,
gap: 4,
},
railButton: {
borderWidth: 1.5,
},
railHint: {
fontSize: 11,
textAlign: 'center',
},
soleRail: {
borderWidth: 1.5,
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
gap: 2,
},
soleRailLabel: {
fontSize: 16,
fontFamily: fontFamily.bold,
},
soleRailHint: {
fontSize: 13,
},
railError: {
fontSize: 13,
},
saveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
saveCopy: {
flex: 1,
gap: 2,
},
saveTitle: {
fontSize: 16,
fontFamily: fontFamily.bold,
},
saveHint: {
fontSize: 14,
},
footer: {
padding: 16,
borderTopWidth: 1,
},
});