Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
const ScannerFrameComponent = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.frame, { borderColor: theme.colors.accent }]}>
|
||||
<View style={[styles.corner, styles.topLeft, { borderColor: theme.colors.accent }]} />
|
||||
<View style={[styles.corner, styles.topRight, { borderColor: theme.colors.accent }]} />
|
||||
<View style={[styles.corner, styles.bottomLeft, { borderColor: theme.colors.accent }]} />
|
||||
<View style={[styles.corner, styles.bottomRight, { borderColor: theme.colors.accent }]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 24,
|
||||
},
|
||||
frame: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderWidth: 4,
|
||||
},
|
||||
topLeft: {
|
||||
top: -1,
|
||||
left: -1,
|
||||
borderRightWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
topRight: {
|
||||
top: -1,
|
||||
right: -1,
|
||||
borderLeftWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
bottomLeft: {
|
||||
bottom: -1,
|
||||
left: -1,
|
||||
borderTopWidth: 0,
|
||||
borderRightWidth: 0,
|
||||
},
|
||||
bottomRight: {
|
||||
bottom: -1,
|
||||
right: -1,
|
||||
borderTopWidth: 0,
|
||||
borderLeftWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const ScannerFrame = memo(ScannerFrameComponent);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { selectUpiDraft } from '../store/paymentsSelectors';
|
||||
import { paymentsActions } from '../store/paymentsSlice';
|
||||
|
||||
export const useUpiFlow = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const draft = useAppSelector(selectUpiDraft);
|
||||
|
||||
return {
|
||||
draft,
|
||||
setMerchant: useCallback(
|
||||
(merchantName: string) => {
|
||||
dispatch(paymentsActions.setMerchant(merchantName));
|
||||
},
|
||||
[dispatch],
|
||||
),
|
||||
setAmount: useCallback(
|
||||
(amount: string) => {
|
||||
dispatch(paymentsActions.setUpiAmount(amount));
|
||||
},
|
||||
[dispatch],
|
||||
),
|
||||
setNote: useCallback(
|
||||
(note: string) => {
|
||||
dispatch(paymentsActions.setUpiNote(note));
|
||||
},
|
||||
[dispatch],
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { ScreenShell, Input, Button, Card, AppModal } from '../../../components';
|
||||
import { useAddBeneficiaryMutation } from '../services/paymentsApi';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { beneficiariesActions } from '../store/beneficiariesSlice';
|
||||
|
||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'AddBeneficiary'>;
|
||||
|
||||
export default function AddBeneficiaryScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const [addBen, { isLoading }] = useAddBeneficiaryMutation();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
accountNumber: '',
|
||||
confirmAccountNumber: '',
|
||||
ifsc: '',
|
||||
nickname: '',
|
||||
});
|
||||
|
||||
const [otpModalVisible, setOtpModalVisible] = useState(false);
|
||||
const [otp, setOtp] = useState('');
|
||||
|
||||
const isValid =
|
||||
form.name.length > 2 &&
|
||||
form.accountNumber.length > 6 &&
|
||||
form.accountNumber === form.confirmAccountNumber &&
|
||||
form.ifsc.length === 11;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValid) return;
|
||||
setOtpModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmOTPAndSave = async () => {
|
||||
if (otp !== '123456') return; // Mock OTP validation
|
||||
|
||||
setOtpModalVisible(false);
|
||||
|
||||
try {
|
||||
const newBeneficiaryObj = {
|
||||
name: form.name,
|
||||
bankName: form.ifsc.startsWith('HDFC') ? 'HDFC Bank' : 'State Bank of India', // mock logic
|
||||
accountMasked: form.accountNumber.slice(-4).padStart(8, 'X'),
|
||||
nickname: form.nickname,
|
||||
};
|
||||
|
||||
const payload = await addBen(newBeneficiaryObj).unwrap();
|
||||
dispatch(beneficiariesActions.addBeneficiary(payload));
|
||||
navigation.goBack();
|
||||
} catch (e) {
|
||||
console.warn("Failed to add beneficiary", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Add Beneficiary" subtitle="Securely save a payee for future transfers" contentContainerStyle={styles.container}>
|
||||
<Card style={styles.formCard}>
|
||||
<Input
|
||||
label="Beneficiary Full Name"
|
||||
placeholder="As per bank records"
|
||||
value={form.name}
|
||||
onChangeText={(v) => setForm({ ...form, name: v })}
|
||||
/>
|
||||
<Input
|
||||
label="Account Number"
|
||||
placeholder="Enter 9-18 digit account number"
|
||||
secureTextEntry
|
||||
keyboardType="number-pad"
|
||||
value={form.accountNumber}
|
||||
onChangeText={(v) => setForm({ ...form, accountNumber: v })}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Account Number"
|
||||
placeholder="Re-enter account number"
|
||||
keyboardType="number-pad"
|
||||
value={form.confirmAccountNumber}
|
||||
onChangeText={(v) => setForm({ ...form, confirmAccountNumber: v })}
|
||||
error={form.confirmAccountNumber.length > 0 && form.accountNumber !== form.confirmAccountNumber ? "Account numbers must match" : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="IFSC Code"
|
||||
placeholder="e.g. SBIN0001234"
|
||||
autoCapitalize="characters"
|
||||
value={form.ifsc}
|
||||
onChangeText={(v) => setForm({ ...form, ifsc: v })}
|
||||
/>
|
||||
<Input
|
||||
label="Nickname (Optional)"
|
||||
placeholder="e.g. Landlord, Office"
|
||||
value={form.nickname}
|
||||
onChangeText={(v) => setForm({ ...form, nickname: v })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Verify & Save"
|
||||
onPress={handleSubmit}
|
||||
disabled={!isValid || isLoading}
|
||||
loading={isLoading}
|
||||
style={styles.submitBtn}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
visible={otpModalVisible}
|
||||
onClose={() => setOtpModalVisible(false)}
|
||||
title="Bank Authentication"
|
||||
>
|
||||
<View style={{ gap: 16 }}>
|
||||
<Input
|
||||
label="Enter 6-digit OTP"
|
||||
placeholder="OTP sent to mobile (Hint: 123456)"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChangeText={setOtp}
|
||||
/>
|
||||
<Button
|
||||
title="Confirm Addition"
|
||||
onPress={confirmOTPAndSave}
|
||||
disabled={otp.length !== 6 || isLoading}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</View>
|
||||
</AppModal>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
formCard: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
submitBtn: {
|
||||
marginTop: 'auto',
|
||||
paddingTop: 32,
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { ScreenShell, Card, Input } from '../../../components';
|
||||
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'BeneficiariesList'>;
|
||||
|
||||
export default function BeneficiariesListScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { data: beneficiaries = [] } = useGetBeneficiariesQuery();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = beneficiaries.filter(b =>
|
||||
b.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
b.bankName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(b.nickname && b.nickname.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
|
||||
const favorites = filtered.filter(b => b.favorite);
|
||||
const others = filtered.filter(b => !b.favorite);
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
navigation.navigate('TransferTypeSelection', { beneficiaryId: id });
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: typeof beneficiaries[0] }) => (
|
||||
<Pressable onPress={() => handleSelect(item.id)} style={styles.payeeItem}>
|
||||
<View style={[styles.avatar, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{item.name[0]}</Text>
|
||||
</View>
|
||||
<View style={styles.payeeDetails}>
|
||||
<Text style={[styles.payee, { color: theme.colors.textPrimary }]}>
|
||||
{item.nickname ? `${item.nickname} (${item.name})` : item.name}
|
||||
</Text>
|
||||
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>
|
||||
{item.bankName} • {item.accountMasked}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Beneficiaries" subtitle="Select a payee to send money" scrollable={false} contentContainerStyle={styles.container}>
|
||||
<View style={styles.searchWrap}>
|
||||
<Input
|
||||
placeholder="Search name, nickname, bank..."
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
leftAccessory={<Ionicons name="search" size={20} color={theme.colors.textMuted} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={[{ id: 'header_fav' } as any, ...favorites, { id: 'header_all' } as any, ...others]}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => {
|
||||
if (item.id === 'header_fav') {
|
||||
return favorites.length > 0 ? (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Favorites ⭐</Text>
|
||||
</View>
|
||||
) : null;
|
||||
}
|
||||
if (item.id === 'header_all') {
|
||||
return others.length > 0 ? (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>All Saved Payees</Text>
|
||||
</View>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<Card style={styles.cardWrapper}>
|
||||
{renderItem({ item })}
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('AddBeneficiary')}
|
||||
>
|
||||
<Ionicons name="add" size={28} color="#FFF" />
|
||||
</Pressable>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
searchWrap: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: TAB_BAR_HEIGHT + 16,
|
||||
},
|
||||
sectionHeader: {
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
cardWrapper: {
|
||||
marginBottom: 8,
|
||||
padding: 12,
|
||||
},
|
||||
payeeItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
payeeDetails: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
payee: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
meta: {
|
||||
fontSize: 13,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
bottom: TAB_BAR_HEIGHT + 20,
|
||||
right: 24,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, ScrollView } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { ScreenShell, Button, Card, AppModal, Input } from '../../../components';
|
||||
import { useGetBeneficiariesQuery, useSubmitBankTransferMutation } from '../services/paymentsApi';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import { accountActions } from '../../home/store/accountSlice';
|
||||
|
||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentConfirmation'>;
|
||||
|
||||
export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const { beneficiaryId, transferType, amount, remarks } = route.params;
|
||||
|
||||
const { data: beneficiaries } = useGetBeneficiariesQuery();
|
||||
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
|
||||
|
||||
const [submitTransfer, { isLoading }] = useSubmitBankTransferMutation();
|
||||
const [otpModalVisible, setOtpModalVisible] = useState(false);
|
||||
const [otp, setOtp] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOtpModalVisible(true);
|
||||
};
|
||||
|
||||
const finalSubmit = async () => {
|
||||
if (otp !== '123456') return; // Mock OTP
|
||||
|
||||
setOtpModalVisible(false);
|
||||
|
||||
try {
|
||||
const response = await submitTransfer({
|
||||
beneficiaryId,
|
||||
amount,
|
||||
transferType,
|
||||
remarks,
|
||||
sourceAccountId: 'ACC123', // Mock source
|
||||
}).unwrap();
|
||||
|
||||
dispatch(accountActions.deductBalance(amount));
|
||||
|
||||
navigation.navigate('TransactionSuccess', {
|
||||
transactionId: response.referenceId,
|
||||
amount,
|
||||
beneficiaryName: beneficiary?.name || 'Unknown',
|
||||
isPending: response.status === 'Process_Pending',
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Transfer failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const DetailRow = ({ label, value, bold = false }: { label: string, value: string, bold?: boolean }) => (
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary, fontWeight: bold ? '800' : '600' }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Confirm Payment" subtitle="Review transaction details" contentContainerStyle={styles.container}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<Card style={styles.confirmCard}>
|
||||
<View style={styles.amountSection}>
|
||||
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Total Amount</Text>
|
||||
<Text style={[styles.amountValue, { color: theme.colors.primary }]}>₹{amount.toLocaleString('en-IN')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
|
||||
<View style={styles.detailsSection}>
|
||||
<DetailRow label="Transfer Mode" value={transferType} bold />
|
||||
<DetailRow label="To Beneficiary" value={beneficiary?.name || ''} />
|
||||
<DetailRow label="Bank Name" value={beneficiary?.bankName || ''} />
|
||||
<DetailRow label="Account Number" value={beneficiary?.accountMasked || ''} />
|
||||
{remarks ? <DetailRow label="Remarks" value={remarks} /> : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<View style={[styles.noticeCard, { backgroundColor: `${theme.colors.info}10`, borderColor: theme.colors.info }]}>
|
||||
<Text style={[styles.noticeText, { color: theme.colors.textPrimary }]}>
|
||||
Funds will be transferred from your Primary Savings Account ending in XXXX4321.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
title="Confirm & Pay"
|
||||
onPress={handleConfirm}
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<AppModal
|
||||
visible={otpModalVisible}
|
||||
onClose={() => setOtpModalVisible(false)}
|
||||
title="Secure Transaction"
|
||||
>
|
||||
<View style={{ gap: 16 }}>
|
||||
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
|
||||
Enter the 6-digit code sent to your registered mobile to authorize this ₹{amount.toLocaleString('en-IN')} transfer.
|
||||
</Text>
|
||||
<Input
|
||||
label="Transaction OTP"
|
||||
placeholder="Enter OTP (Hint: 123456)"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChangeText={setOtp}
|
||||
/>
|
||||
<Button
|
||||
title="Authorize Payment"
|
||||
onPress={finalSubmit}
|
||||
disabled={otp.length !== 6 || isLoading}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</View>
|
||||
</AppModal>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
confirmCard: {
|
||||
padding: 20,
|
||||
},
|
||||
amountSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
gap: 4,
|
||||
},
|
||||
amountLabel: {
|
||||
fontSize: 14,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
fontWeight: '600',
|
||||
},
|
||||
amountValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '900',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginBottom: 20,
|
||||
opacity: 0.5,
|
||||
},
|
||||
detailsSection: {
|
||||
gap: 16,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
textAlign: 'right',
|
||||
},
|
||||
noticeCard: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
noticeText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(0,0,0,0.05)',
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, ScrollView, Pressable } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { ScreenShell, Input, Button, Card } from '../../../components';
|
||||
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import type { RootState } from '../../../app/store';
|
||||
import { accountActions } from '../../home/store/accountSlice';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
|
||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentForm'>;
|
||||
|
||||
export function PaymentFormScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const beneficiaryId = route.params?.beneficiaryId;
|
||||
const transferType = route.params?.transferType;
|
||||
const { data: beneficiaries } = useGetBeneficiariesQuery();
|
||||
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [amount, setAmount] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
const accountState = useSelector((state: RootState) => state.account);
|
||||
const accounts = accountState.accounts;
|
||||
const selectedAccount = accounts.find(a => a.id === accountState.selectedAccountId) || accounts[0];
|
||||
const currentBalance = selectedAccount?.availableBalance || 0;
|
||||
|
||||
const numAmount = parseFloat(amount.replace(/,/g, ''));
|
||||
const isSufficientBalance = !isNaN(numAmount) && numAmount <= currentBalance;
|
||||
|
||||
let amountError = '';
|
||||
if (amount.length > 0) {
|
||||
if (isNaN(numAmount) || numAmount <= 0) {
|
||||
amountError = 'Please enter a valid amount';
|
||||
} else if (!isSufficientBalance) {
|
||||
amountError = 'Insufficient balance';
|
||||
} else if (transferType === 'RTGS' && numAmount < 200000) {
|
||||
amountError = 'RTGS requires a minimum of ₹2,00,000';
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = !amountError && numAmount > 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isValid) {
|
||||
navigation.navigate('PaymentConfirmation', {
|
||||
beneficiaryId,
|
||||
transferType,
|
||||
amount: numAmount,
|
||||
remarks,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Enter Amount" subtitle={`${transferType} Transfer`} contentContainerStyle={styles.container} scrollable={false}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.payeeSection}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Paying to</Text>
|
||||
<Text style={[styles.payeeName, { color: theme.colors.textPrimary }]}>{beneficiary?.name}</Text>
|
||||
<Text style={[styles.payeeMeta, { color: theme.colors.textSecondary }]}>{beneficiary?.bankName} • {beneficiary?.accountMasked}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.formSection}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Debit From</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.accountSelector}>
|
||||
{accounts.map(acc => {
|
||||
const isSelected = acc.id === selectedAccount?.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={acc.id}
|
||||
onPress={() => dispatch(accountActions.setSelectedAccount(acc.id))}
|
||||
style={[
|
||||
styles.accountChip,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? theme.colors.primary : 'transparent'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.accountType, { color: isSelected ? theme.colors.primary : theme.colors.textPrimary }]}>{acc.type}</Text>
|
||||
<Text style={[styles.accountMask, { color: theme.colors.textSecondary }]}>{acc.maskedNumber}</Text>
|
||||
<Text style={[styles.accountBal, { color: theme.colors.textPrimary }]}>{formatCurrency(acc.availableBalance)}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Input
|
||||
label="Amount (₹)"
|
||||
placeholder="0.00"
|
||||
keyboardType="numeric"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
error={amountError}
|
||||
style={styles.amountInput}
|
||||
/>
|
||||
<Text style={[styles.balanceText, { color: theme.colors.textSecondary, textAlign: 'right' }]}>
|
||||
Avail. Bal: ₹{currentBalance.toLocaleString('en-IN')}
|
||||
</Text>
|
||||
<Input
|
||||
label="Remarks (Optional)"
|
||||
placeholder="What's this for?"
|
||||
value={remarks}
|
||||
onChangeText={setRemarks}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Proceed to Pay"
|
||||
onPress={handleSubmit}
|
||||
disabled={!isValid}
|
||||
style={styles.btn}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
},
|
||||
card: {
|
||||
padding: 20,
|
||||
gap: 16,
|
||||
},
|
||||
payeeSection: {
|
||||
gap: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
payeeName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
payeeMeta: {
|
||||
fontSize: 14,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
marginVertical: 4,
|
||||
},
|
||||
formSection: {
|
||||
gap: 16,
|
||||
},
|
||||
accountSelector: {
|
||||
gap: 12,
|
||||
paddingRight: 10,
|
||||
},
|
||||
accountChip: {
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
minWidth: 130,
|
||||
gap: 2,
|
||||
},
|
||||
accountType: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
accountMask: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
accountBal: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
marginTop: 4,
|
||||
},
|
||||
amountInput: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
balanceText: {
|
||||
fontSize: 12,
|
||||
marginTop: -8,
|
||||
},
|
||||
btn: {
|
||||
marginTop: 'auto',
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import React from 'react';
|
||||
import { FlatList, Pressable, StyleSheet, Text, View, ScrollView } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Card, ScreenShell } from '../../../components';
|
||||
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
|
||||
|
||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentsHub'>;
|
||||
|
||||
export default function PaymentsHomeScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const transactions = useAppSelector(state => state.account.transactions);
|
||||
const { data: beneficiaries = [] } = useGetBeneficiariesQuery();
|
||||
|
||||
// Simulated stats
|
||||
const dailyLimit = 500000;
|
||||
const utilized = 125000;
|
||||
const remaining = dailyLimit - utilized;
|
||||
const limitPercentage = (utilized / dailyLimit) * 100;
|
||||
|
||||
const paymentTransactions = transactions.filter(t =>
|
||||
t.direction === 'debit' && ['Transfer', 'Payment', 'Bill Pay'].includes(t.category)
|
||||
).slice(0, 4);
|
||||
|
||||
const ActionCard = ({ title, icon, color, onPress }: { title: string, icon: string, color: string, onPress: () => void }) => (
|
||||
<Pressable onPress={onPress} style={styles.actionCardWrapper}>
|
||||
<Card style={styles.actionCard}>
|
||||
<View style={[styles.actionIcon, { backgroundColor: `${color}15` }]}>
|
||||
<Ionicons name={icon as any} size={24} color={color} />
|
||||
</View>
|
||||
<Text style={[styles.actionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Payments"
|
||||
subtitle="Fast, Secure & Seamless"
|
||||
scrollable={false}
|
||||
contentContainerStyle={styles.shell}
|
||||
>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.container}>
|
||||
|
||||
{/* Daily Limit Card */}
|
||||
<Card style={styles.limitCard}>
|
||||
<View style={styles.limitHeader}>
|
||||
<View>
|
||||
<Text style={[styles.limitLabel, { color: theme.colors.textSecondary }]}>Daily Transfer Limit</Text>
|
||||
<Text style={[styles.limitValue, { color: theme.colors.textPrimary }]}>₹{remaining.toLocaleString('en-IN')} remaining</Text>
|
||||
</View>
|
||||
<Ionicons name="shield-checkmark" size={24} color={theme.colors.success} />
|
||||
</View>
|
||||
<View style={[styles.progressBarBg, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View style={[styles.progressBarFill, { backgroundColor: theme.colors.primary, width: `${limitPercentage}%` }]} />
|
||||
</View>
|
||||
<Text style={[styles.limitFooter, { color: theme.colors.textMuted }]}>Utilized: ₹{utilized.toLocaleString('en-IN')} of ₹{dailyLimit.toLocaleString('en-IN')}</Text>
|
||||
</Card>
|
||||
|
||||
{/* Primary Actions Grid */}
|
||||
<View style={styles.actionGrid}>
|
||||
<ActionCard
|
||||
title="Send Money"
|
||||
icon="send"
|
||||
color={theme.colors.primary}
|
||||
onPress={() => navigation.navigate('BeneficiariesList')}
|
||||
/>
|
||||
<ActionCard
|
||||
title="Add Payee"
|
||||
icon="person-add"
|
||||
color="#10B981"
|
||||
onPress={() => navigation.navigate('AddBeneficiary')}
|
||||
/>
|
||||
<ActionCard
|
||||
title="Manage"
|
||||
icon="settings-outline"
|
||||
color="#6366F1"
|
||||
onPress={() => navigation.navigate('BeneficiariesList')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Quick Pay Section */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Quick Pay</Text>
|
||||
<Pressable onPress={() => navigation.navigate('BeneficiariesList')}>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: '700', fontSize: 13 }}>See All</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
data={beneficiaries.slice(0, 6)}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.quickPayList}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('TransferTypeSelection', { beneficiaryId: item.id })}
|
||||
style={styles.quickPayItem}
|
||||
>
|
||||
<View style={[styles.quickPayAvatar, { backgroundColor: `${theme.colors.primary}10`, borderColor: theme.colors.primary }]}>
|
||||
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{item.name[0]}</Text>
|
||||
</View>
|
||||
<Text numberOfLines={1} style={[styles.quickPayName, { color: theme.colors.textPrimary }]}>{item.nickname || item.name.split(' ')[0]}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary, marginBottom: 12 }]}>Recent Activity</Text>
|
||||
{paymentTransactions.map((item, index) => (
|
||||
<Card key={item.id} style={[styles.transactionCard, { marginBottom: 8 }]}>
|
||||
<View style={[styles.transIcon, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="arrow-up-circle" size={20} color={theme.colors.error} />
|
||||
</View>
|
||||
<View style={styles.transDetails}>
|
||||
<Text style={[styles.transTarget, { color: theme.colors.textPrimary }]}>{item.counterparty || item.title}</Text>
|
||||
<Text style={[styles.transMeta, { color: theme.colors.textSecondary }]}>{formatDate(item.occurredAt)} • {item.category}</Text>
|
||||
</View>
|
||||
<Text style={[styles.transAmount, { color: theme.colors.textPrimary }]}>-₹{item.amount.toLocaleString('en-IN')}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shell: {
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
container: {
|
||||
padding: 16,
|
||||
gap: 24,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
limitCard: {
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
limitHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
limitLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
limitValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
marginTop: 2,
|
||||
},
|
||||
progressBarBg: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
},
|
||||
limitFooter: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionCardWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
actionCard: {
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
section: {
|
||||
gap: 12,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
quickPayList: {
|
||||
gap: 16,
|
||||
paddingRight: 20,
|
||||
},
|
||||
quickPayItem: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: 64,
|
||||
},
|
||||
quickPayAvatar: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
borderWidth: 1.5,
|
||||
borderStyle: 'dashed',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
},
|
||||
quickPayName: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
transactionCard: {
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
transIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
transDetails: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
transTarget: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
transMeta: {
|
||||
fontSize: 11,
|
||||
},
|
||||
transAmount: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, Text, View, BackHandler } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withDelay,
|
||||
withSequence,
|
||||
withTiming
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Button, Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransactionSuccess'>;
|
||||
|
||||
export function TransactionSuccessScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const { transactionId, amount, beneficiaryName, isPending } = route.params;
|
||||
|
||||
const scale = useSharedValue(0);
|
||||
const opacity = useSharedValue(0);
|
||||
const translateY = useSharedValue(20);
|
||||
|
||||
useEffect(() => {
|
||||
scale.value = withSpring(1);
|
||||
opacity.value = withDelay(300, withTiming(1, { duration: 500 }));
|
||||
translateY.value = withDelay(300, withSpring(0));
|
||||
|
||||
// Disable back button
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
return () => backHandler.remove();
|
||||
}, []);
|
||||
|
||||
const iconStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const contentStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<ScreenShell title="Status" scrollable={false} contentContainerStyle={styles.container}>
|
||||
<View style={styles.centerContent}>
|
||||
<Animated.View style={[styles.iconContainer, { backgroundColor: isPending ? theme.colors.warning : theme.colors.success }, iconStyle]}>
|
||||
<Ionicons name={isPending ? "time" : "checkmark"} size={60} color="#FFF" />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.textWrapper, contentStyle]}>
|
||||
<Text style={[styles.statusTitle, { color: theme.colors.textPrimary }]}>
|
||||
{isPending ? 'Transfer Initiated' : 'Transfer Successful'}
|
||||
</Text>
|
||||
<Text style={[styles.statusSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{isPending
|
||||
? 'NEFT process takes approx 30 mins to reflect.'
|
||||
: `Amount has been successfully sent to ${beneficiaryName}.`}
|
||||
</Text>
|
||||
|
||||
<Card style={styles.receiptCard}>
|
||||
<View style={styles.receiptRow}>
|
||||
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Transaction ID</Text>
|
||||
<Text style={[styles.receiptValue, { color: theme.colors.textPrimary }]}>{transactionId}</Text>
|
||||
</View>
|
||||
<View style={styles.receiptRow}>
|
||||
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Amount</Text>
|
||||
<Text style={[styles.receiptValue, { color: theme.colors.primary, fontWeight: '800' }]}>₹{amount.toLocaleString('en-IN')}</Text>
|
||||
</View>
|
||||
<View style={styles.receiptRow}>
|
||||
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Timestamp</Text>
|
||||
<Text style={[styles.receiptValue, { color: theme.colors.textPrimary }]}>{new Date().toLocaleString()}</Text>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
title="Back to Home"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('PaymentsHub')}
|
||||
/>
|
||||
<Button
|
||||
title="Download Receipt"
|
||||
variant="ghost"
|
||||
onPress={() => {}}
|
||||
/>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
centerContent: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
},
|
||||
textWrapper: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
},
|
||||
statusTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '900',
|
||||
textAlign: 'center',
|
||||
},
|
||||
statusSubtitle: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 20,
|
||||
lineHeight: 22,
|
||||
},
|
||||
receiptCard: {
|
||||
width: '100%',
|
||||
marginTop: 32,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
receiptRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
receiptLabel: {
|
||||
fontSize: 13,
|
||||
},
|
||||
receiptValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
gap: 8,
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import React 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 { ScreenShell, Card } from '../../../components';
|
||||
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferTypeSelection'>;
|
||||
|
||||
export default function TransferTypeScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const { beneficiaryId } = route.params;
|
||||
const { data: beneficiaries } = useGetBeneficiariesQuery();
|
||||
const beneficiary = beneficiaries?.find(b => b.id === beneficiaryId);
|
||||
|
||||
const handleSelect = (type: 'IMPS' | 'NEFT' | 'RTGS') => {
|
||||
navigation.navigate('PaymentForm', { beneficiaryId, transferType: type });
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Transfer Type" subtitle={`Sending to ${beneficiary?.name || 'Beneficiary'}`} contentContainerStyle={styles.container}>
|
||||
<Pressable onPress={() => handleSelect('IMPS')}>
|
||||
<Card style={styles.typeCard}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<Ionicons name="flash" size={24} color={theme.colors.success} />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>IMPS Transfer</Text>
|
||||
<View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
|
||||
<Text style={styles.badgeText}>RECOMMENDED</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.desc, { color: theme.colors.textSecondary }]}>Immediate Payment Service. Instant transfer, 24x7 availability.</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
|
||||
</Card>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={() => handleSelect('NEFT')}>
|
||||
<Card style={styles.typeCard}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${theme.colors.info}15` }]}>
|
||||
<Ionicons name="time" size={24} color={theme.colors.info} />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>NEFT Transfer</Text>
|
||||
<Text style={[styles.desc, { color: theme.colors.textSecondary }]}>National Electronic Fund Transfer. Processed in half-hourly batches.</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
|
||||
</Card>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={() => handleSelect('RTGS')}>
|
||||
<Card style={styles.typeCard}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${theme.colors.warning}15` }]}>
|
||||
<Ionicons name="briefcase" size={24} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>RTGS Transfer</Text>
|
||||
<Text style={[styles.desc, { color: theme.colors.textSecondary }]}>Real-Time Gross Settlement. Recommended for high-value transfers.</Text>
|
||||
<Text style={[styles.condition, { color: theme.colors.error }]}>Min amount: ₹2,00,000</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
|
||||
</Card>
|
||||
</Pressable>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
typeCard: {
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
iconBox: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFF',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
},
|
||||
desc: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
condition: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 4,
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Beneficiary } from '../../../types/app';
|
||||
import { demoBeneficiaries } from '../../../constants/mockData';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { resolveWithMock } from '../../../services/api/serviceHelpers';
|
||||
|
||||
export interface BankTransferRequest {
|
||||
beneficiaryId: string;
|
||||
amount: number;
|
||||
transferType: 'IMPS' | 'NEFT' | 'RTGS';
|
||||
remarks?: string;
|
||||
sourceAccountId: string;
|
||||
}
|
||||
|
||||
export interface BankTransferResponse {
|
||||
referenceId: string;
|
||||
status: 'Process_Success' | 'Process_Pending';
|
||||
}
|
||||
|
||||
export const paymentsApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getBeneficiaries: builder.query<Beneficiary[], void>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: demoBeneficiaries,
|
||||
request: async () => {
|
||||
// Real API implementation
|
||||
// const response = await apiClient.get<Beneficiary[]>('/beneficiaries');
|
||||
// return response.data;
|
||||
return [] as Beneficiary[];
|
||||
},
|
||||
}),
|
||||
providesTags: ['Payments'],
|
||||
}),
|
||||
addBeneficiary: builder.mutation<Beneficiary, Partial<Beneficiary>>({
|
||||
queryFn: (newBen) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
id: `ben_${Date.now()}`,
|
||||
name: newBen.name || 'Unknown',
|
||||
bankName: newBen.bankName || 'Unknown Bank',
|
||||
accountMasked: newBen.accountMasked || 'xxxx0000',
|
||||
favorite: false,
|
||||
} as Beneficiary,
|
||||
request: async () => {
|
||||
// const response = await apiClient.post('/beneficiaries/add', newBen);
|
||||
// return response.data;
|
||||
return {} as Beneficiary;
|
||||
}
|
||||
}),
|
||||
invalidatesTags: ['Payments'],
|
||||
}),
|
||||
submitBankTransfer: builder.mutation<BankTransferResponse, BankTransferRequest>({
|
||||
queryFn: (req) =>
|
||||
resolveWithMock({
|
||||
mockData: () => ({
|
||||
referenceId: `${req.transferType}${Date.now()}`,
|
||||
status: req.transferType === 'NEFT' ? 'Process_Pending' : 'Process_Success',
|
||||
}),
|
||||
request: async () => {
|
||||
// const response = await apiClient.post<BankTransferResponse>('/transactions/transfer', req);
|
||||
// return response.data;
|
||||
return { referenceId: '', status: 'Process_Success' };
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['Payments', 'Transactions'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetBeneficiariesQuery, useAddBeneficiaryMutation, useSubmitBankTransferMutation } = paymentsApi;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { Beneficiary } from '../../../types/app';
|
||||
import { demoBeneficiaries } from '../../../constants/mockData';
|
||||
|
||||
interface BeneficiariesState {
|
||||
list: Beneficiary[];
|
||||
}
|
||||
|
||||
const initialState: BeneficiariesState = {
|
||||
list: demoBeneficiaries,
|
||||
};
|
||||
|
||||
const beneficiariesSlice = createSlice({
|
||||
name: 'beneficiaries',
|
||||
initialState,
|
||||
reducers: {
|
||||
setBeneficiaries(state, action: PayloadAction<Beneficiary[]>) {
|
||||
state.list = action.payload;
|
||||
},
|
||||
addBeneficiary(state, action: PayloadAction<Beneficiary>) {
|
||||
state.list.push(action.payload);
|
||||
},
|
||||
removeBeneficiary(state, action: PayloadAction<string>) {
|
||||
state.list = state.list.filter(b => b.id !== action.payload);
|
||||
},
|
||||
markAsFavorite(state, action: PayloadAction<string>) {
|
||||
const b = state.list.find(item => item.id === action.payload);
|
||||
if (b) {
|
||||
b.favorite = !b.favorite;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const beneficiariesActions = beneficiariesSlice.actions;
|
||||
export const beneficiariesReducer = beneficiariesSlice.reducer;
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RootState } from '../../../app/store';
|
||||
|
||||
export const selectPaymentsState = (state: RootState) => state.payments;
|
||||
export const selectUpiDraft = (state: RootState) => state.payments.draft;
|
||||
export const selectRecentPayees = (state: RootState) => state.payments.recentPayees;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { AsyncStatus, Beneficiary, UpiDraft } from '../../../types/app';
|
||||
import { seedRecentPayees } from './paymentsThunk';
|
||||
|
||||
interface PaymentsState {
|
||||
status: AsyncStatus;
|
||||
draft: UpiDraft;
|
||||
recentPayees: Beneficiary[];
|
||||
referenceId: string | null;
|
||||
}
|
||||
|
||||
const initialState: PaymentsState = {
|
||||
status: 'idle',
|
||||
draft: {
|
||||
amount: '',
|
||||
note: '',
|
||||
},
|
||||
recentPayees: [],
|
||||
referenceId: null,
|
||||
};
|
||||
|
||||
const paymentsSlice = createSlice({
|
||||
name: 'payments',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMerchant(state, action: PayloadAction<string>) {
|
||||
state.draft.merchantName = action.payload;
|
||||
},
|
||||
setUpiAmount(state, action: PayloadAction<string>) {
|
||||
state.draft.amount = action.payload;
|
||||
},
|
||||
setUpiNote(state, action: PayloadAction<string>) {
|
||||
state.draft.note = action.payload;
|
||||
},
|
||||
setUpiReference(state, action: PayloadAction<string>) {
|
||||
state.referenceId = action.payload;
|
||||
},
|
||||
resetUpiFlow(state) {
|
||||
state.draft = {
|
||||
amount: '',
|
||||
note: '',
|
||||
};
|
||||
state.referenceId = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(seedRecentPayees.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
})
|
||||
.addCase(seedRecentPayees.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.recentPayees = action.payload;
|
||||
})
|
||||
.addCase(seedRecentPayees.rejected, (state) => {
|
||||
state.status = 'failed';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const paymentsActions = paymentsSlice.actions;
|
||||
export const paymentsReducer = paymentsSlice.reducer;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { demoBeneficiaries } from '../../../constants/mockData';
|
||||
import type { Beneficiary } from '../../../types/app';
|
||||
import { sleep } from '../../../utils/async';
|
||||
|
||||
export const seedRecentPayees = createAsyncThunk<Beneficiary[]>(
|
||||
'payments/seedRecentPayees',
|
||||
async () => {
|
||||
await sleep(120);
|
||||
return demoBeneficiaries.filter((beneficiary) => beneficiary.upiId);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export type { UpiDraft } from '../../../types/app';
|
||||
@@ -0,0 +1 @@
|
||||
export const isValidUpiPin = (value: string) => /^[0-9]{4,6}$/.test(value);
|
||||
Reference in New Issue
Block a user