Fix form keyboard issues and sticky footers in agent screens

This commit is contained in:
Abin Toppo
2026-05-26 16:34:05 +05:30
commit 9ac5e95d52
410 changed files with 86650 additions and 0 deletions
@@ -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);
+32
View File
@@ -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);
},
);
+1
View File
@@ -0,0 +1 @@
export type { UpiDraft } from '../../../types/app';
@@ -0,0 +1 @@
export const isValidUpiPin = (value: string) => /^[0-9]{4,6}$/.test(value);