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