Add TPIN creation, TPIN-authorised transfers, MPIN reset and transfer history
- Create TPIN: current MPIN -> OTP -> encrypted TPIN via /tpin/create/*, from Services and from the transfer screen on TPIN_NOT_SET - IMPS initiate now sends encryptedTpin instead of encryptedMpin; handles INVALID_TPIN and TPIN_NOT_SET - Share one RSA-OAEP encryption and key-retry path for MPIN and TPIN - Forgot MPIN on sign-in and Reset MPIN in the Security Center - Fund transfer history list and detail, reachable from Home - Brand logo on sign-in and palette updates Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
abb14d5768
commit
f1def81eb7
@@ -0,0 +1,236 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
|
||||
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useGetFundTransferDetailQuery } from '../../payments/services/paymentsApi';
|
||||
import { MODE_LABEL, humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<HomeStackParamList, 'TransferDetail'>,
|
||||
BottomTabScreenProps<MainTabParamList>
|
||||
>;
|
||||
|
||||
const when = (iso: string | null) => (iso ? `${formatDate(iso)}, ${formatTime(iso)}` : null);
|
||||
|
||||
export default function TransferDetailScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const showError = useApiErrorToast();
|
||||
const { transferId } = route.params;
|
||||
|
||||
// Always fetched fresh on open: reopening the screen is one of the two ways
|
||||
// the guide says a pending transfer should re-check its state.
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferDetailQuery(transferId, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
// A transfer that is not on this profile: close the detail and say why.
|
||||
useEffect(() => {
|
||||
if (apiError?.code === 'TRANSACTION_NOT_FOUND') {
|
||||
showError(apiError);
|
||||
navigation.goBack();
|
||||
}
|
||||
}, [apiError, navigation, showError]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ScreenShell title="Transfer details">
|
||||
<Loader />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
const copy = describeError(apiError?.code);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Transfer details">
|
||||
<EmptyState
|
||||
title={copy.title}
|
||||
description={apiError?.message || copy.description}
|
||||
actionLabel="Try again"
|
||||
onAction={refetch}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const status = statusCopy(data.status);
|
||||
const colors = toneColors(status.tone, theme.colors);
|
||||
const showReason =
|
||||
Boolean(data.failureReason) && (data.status === 'FAILED' || data.status === 'MANUAL_REVIEW');
|
||||
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
['Paid to', data.payeeName],
|
||||
[data.mode === 'P2P' ? 'Mobile' : 'Account', data.payeeDisplay],
|
||||
['IFSC', data.beneficiaryIfsc],
|
||||
['MMID', data.beneficiaryMmid],
|
||||
['Type', `IMPS · ${MODE_LABEL[data.mode] ?? data.mode}`],
|
||||
['Remark', data.remark],
|
||||
['Initiated', when(data.createdAt)],
|
||||
['Last updated', when(data.updatedAt)],
|
||||
];
|
||||
|
||||
return (
|
||||
<ScreenShell title="Transfer details" scrollable={false}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching}
|
||||
onRefresh={refetch}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* ── the outcome ───────────────────────────────────────────────── */}
|
||||
<Card style={styles.hero}>
|
||||
<View style={[styles.statusIcon, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name={status.icon} size={30} color={colors.tone} />
|
||||
</View>
|
||||
<Text style={[theme.text.balance, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(data.amount)}
|
||||
</Text>
|
||||
<View style={[styles.badge, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
{/* `simple` break strategy: Android's default `highQuality` measures
|
||||
a custom font as fitting on one line, then wraps it on draw, so
|
||||
the last word ("pending") was clipped outside the pill. */}
|
||||
<Text
|
||||
textBreakStrategy="simple"
|
||||
style={[theme.text.bodyStrong, styles.badgeText, { color: colors.tone }]}
|
||||
>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
{showReason ? (
|
||||
<Text style={[theme.text.body, styles.centre, { color: theme.colors.danger }]}>
|
||||
{humaniseReason(data.failureReason as string)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{data.status === 'PENDING_RECONCILIATION' || data.status === 'PROCESSING' ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
message="Your bank is still confirming this transfer. Don't send it again — refresh in a little while to see the final status."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'PENDING_OTP' ? (
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="This transfer was never confirmed with an OTP, so no money has left your account."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'MANUAL_REVIEW' ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
message="Your bank is reviewing this transfer. Contact support and quote the reference below if you need help."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ── the reference the customer quotes ─────────────────────────── */}
|
||||
<Card style={styles.section}>
|
||||
<Text style={[theme.text.eyebrow, { color: theme.colors.textMuted }]}>Reference</Text>
|
||||
<Text selectable style={[theme.text.code, { color: theme.colors.textPrimary }]}>
|
||||
{data.referenceId}
|
||||
</Text>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||
Press and hold to copy
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.section}>
|
||||
{rows
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([label, value]) => (
|
||||
<View key={label} style={styles.detailRow}>
|
||||
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text
|
||||
style={[theme.text.bodyStrong, styles.detailValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{status.refreshable ? (
|
||||
<Button
|
||||
title={isFetching ? 'Checking…' : 'Refresh status'}
|
||||
variant="secondary"
|
||||
onPress={refetch}
|
||||
disabled={isFetching}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'MANUAL_REVIEW' || data.status === 'FAILED' ? (
|
||||
<Button
|
||||
title="Get help"
|
||||
variant="ghost"
|
||||
onPress={() => navigation.navigate('Profile', { screen: 'HelpSupport' })}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
gap: 14,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
hero: {
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 24,
|
||||
},
|
||||
statusIcon: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
badge: {
|
||||
maxWidth: '100%',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
},
|
||||
centre: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
section: {
|
||||
gap: 6,
|
||||
padding: 16,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
detailValue: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user